Dictionary forms
When the form is a map, make the map the root. A
z.recordschema at the top level gives you a dictionary form:form.valuesis the map itself, andform.record()iterates its entries.
- Category
- Schema feature
- Form root
z.record(keySchema, valueSchema)- Entry view
form.record()- Default
- empty map
The demo is a team roster keyed by member id. The ids are data you only learn at run time, so the schema root is a z.record(z.string(), …), a string-keyed dictionary of members. The whole form is that map: edit a member, add one, drop one, and watch form.values track it below.
The schema
A dictionary form declares a z.record schema as the root, not nested under a key. The key schema constrains what counts as a valid key; the value schema validates each entry:
import { useForm } from 'attaform/zod'
import { z } from 'zod'
const schema = z.record(
z.string(),
z.object({ role: z.enum(['admin', 'editor', 'viewer']), tier: z.number() })
)
const form = useForm({ schema })
There is no wrapper key. form.values reads as Record<string, { role: 'admin' | 'editor' | 'viewer'; tier: number }>, the map itself.
form.values is the dictionary
Because the record is the root, form.values is the whole map, and entries bind through their own key:
form.values() // the whole map: Record<string, { role; tier }>
form.values()['ada'] // one entry, or undefined
form.register('ada.role') // bind an entry sub-field
form.errors['ada']?.['tier'] // ValidationError[] for that entry's tier
The | undefined on an entry read comes from noUncheckedIndexedAccess, the same way a record field reads anywhere else. Reach for ?? defaults at the call site when you need one.
Iterating with form.record()
form.record() with no argument is the root entry view: one FieldState per entry, keyed by the entry's own key. It is the root counterpart of form.record(path), which views a nested record. The keys come from the form, so you render whatever entries exist without keeping a parallel list of your own:
<template>
<div v-for="(member, id) in form.record()" :key="id">
<label>{{ id }}</label>
<input v-register="form.register(`${id}.tier`)" type="number" />
<small v-if="member.showErrors">{{ member.firstError?.message }}</small>
</div>
</template>
Each member is a live FieldState, the same surface form.fields exposes, so every read stays current as the user types. Binding still flows through form.register with the entry path, which the key supplies.
Adding, editing, and removing entries
A dictionary form carries its own keys, so you grow and shrink it through setValue. Editing and adding are ordinary path writes:
form.setValue('ada.tier', 4) // edit one entry's field
form.setValue('mae', { role: 'viewer', tier: 1 }) // add a key that isn't there yet
Removing is the one case the root changes. A nested record drops a key by rewriting its container path; a record root has no container path, so you rewrite the form itself. Passing a single argument to setValue is a whole-form write, and for a dictionary form the whole form is the map:
const next = { ...form.values() }
delete next['ada']
form.setValue(next) // write the map back without that key
Surviving entries keep their field states and component instances across the write; only the dropped row unmounts.
Defaults
A bare record root defaults to an empty map, ready to grow:
const form = useForm({ schema })
form.values() // {}
Seed initial entries with defaultValues, the same shape as the form value:
const form = useForm({
schema,
defaultValues: { ada: { role: 'admin', tier: 3 } },
})
When the keys are fixed, reach for an object
Dictionary forms are for keys you learn at run time. When the keys are known at schema-write time, a z.object root is the better fit: fixed keys, a distinct type per field, and compile-time autocomplete on each one. The two compose freely. An object form can hold a z.record field (see Records & maps), and a dictionary form's value schema can be any Zod schema, including objects, arrays, and nested records.
Where to next
- Records & maps:
z.recordas a field inside an object form, plus thez.mapprimitive. record: the entry view,form.record(path)for a nested record andform.record()for the root.setValue: the writes that grow, edit, and shrink a dictionary.- Nested objects: fixed-shape composition, the alternative when the keys are known.