Arrays & tuples
Two container shapes, one binding pattern. Numeric segments in the path point to elements, and the field-array helpers cover every mutation a list-shaped input needs.
The demo binds an array of objects (todo items) and a fixed-length tuple (a date range with exactly two endpoints). Both flow through the same register('path.index.subpath') shape; the schema's array-ness is what unlocks append / remove / swap.
z.array(elem): variable length
const schema = z.object({
todos: z.array(
z.object({
title: z.string().min(1, 'Title is required'),
done: z.boolean().default(false),
})
),
})
const form = useForm({ schema, defaultValues: { todos: [] } })
Each element gets a numeric segment in its path:
form.values.todos[0].title // string
form.values.todos[2].done // boolean
form.register('todos.3.title') // path autocomplete works
form.errors.todos[1]?.title // ValidationError[] | undefined
Templates iterate with v-for:
<template>
<div v-for="(_, i) in form.values.todos" :key="i" class="row">
<input v-register="form.register(`todos.${i}.title`)" />
<input v-register="form.register(`todos.${i}.done`)" type="checkbox" />
<button type="button" @click="form.remove('todos', i)">−</button>
</div>
<button type="button" @click="form.append('todos', { title: '', done: false })">Add todo</button>
</template>
The i keyed loop pattern is fine for static lists. For lists where items can reorder, use a stable per-row identifier instead; see Performance for the keying discussion.
Field-array helpers
Seven helpers cover the common mutations:
| Method | What it does |
|---|---|
form.append('path', element) | Push to the end. |
form.prepend('path', element) | Unshift to the front. |
form.insert('path', index, element) | Insert at a specific index. |
form.remove('path', index) | Remove at an index. |
form.swap('path', i, j) | Swap two positions. |
form.move('path', from, to) | Move an element to a new index. |
form.replace('path', elements[]) | Replace the whole array. |
All seven preserve sibling state where applicable (touched, focused, errors), reorder field-state to follow the items, and record one undo position per call; see Field-array mutations for the full reference.
z.tuple([a, b, c]): fixed length
const schema = z.object({
dateRange: z.tuple([z.date(), z.date()]),
})
const form = useForm({
schema,
defaultValues: { dateRange: [new Date(), new Date()] },
})
Each position is its own slot with its own type:
form.values.dateRange[0] // Date
form.values.dateRange[1] // Date
form.register('dateRange.0') // path autocomplete narrows to position 0
form.register('dateRange.1') // position 1
form.register('dateRange.2') // type error (tuple has only 2 positions)
Tuples don't expose the field-array helpers; form.append('dateRange', new Date()) is a type error because the tuple has a fixed shape. For mixed-shape sequences (a [string, number, boolean]), tuples are how you say "exactly this layout, in this order."
When to pick which
- Array: when the list grows and shrinks at runtime, and every element has the same shape. Todos, tags, line items, attachments.
- Tuple: when the sequence has a fixed length and the positions may differ in type or meaning. Date ranges, coordinate pairs, RGB color triples, latitude, longitude tuples.
If you find yourself reaching for z.array(z.union([a, b])) to mean "exactly one of A followed by exactly one of B," reach for a tuple instead; z.tuple([a, b]) says the same thing, more precisely.
Errors land where the schema expects
Errors per element:
form.errors.todos[0]?.title // ValidationError[] for todos[0].title
form.errors.dateRange[1] // ValidationError[] for dateRange[1]
The aggregate form.meta.errors flattens every leaf's errors into a single list. Cross-element refinements (a .refine on the whole array) land on the array path itself rather than a specific element. form.errors.todos[0] (note the [0] index after the .errors.todos access) reads the first error attached to the array, which is the cross-element one.
Async element-level refinements
Element-level async refinements work the way you'd expect. Each element validates independently:
const schema = z.object({
skus: z.array(
z.string().refine(async (sku) => await api.checkSku(sku), {
message: 'Unknown SKU',
})
),
})
// form.errors.skus[2]: pending until the async refinement settles
// for that element
See Async refinements for cancellation semantics and the "pending" state.
Where to next
- Field-array mutations: the seven helpers in depth, with element keying patterns.
- Records & maps: when the keys aren't numeric indices.
- Nested objects: fixed-shape composition vs. variable-length sequences.