Records & maps
Dictionaries when the keys aren't known at schema-write time:
z.recordfor the common case,z.mapwhen you need theMap<K, V>primitive and structured-clone fidelity.
The demo binds a record of per-user preferences. The keys are user IDs you don't know at compile time, so the schema declares z.record(z.string(), z.boolean()), a string-keyed dictionary of booleans. Each key binds dynamically via register(\prefs.${userId}`)`.
z.record(keySchema, valueSchema)
const schema = z.object({
prefs: z.record(z.string(), z.boolean()),
scores: z.record(z.string(), z.number()),
})
const form = useForm({
schema,
defaultValues: { prefs: {}, scores: {} },
})
Each value gets a dynamic-key path segment:
form.values.prefs['user-42'] // boolean | undefined
form.register('prefs.user-42') // path autocomplete; the key segment is dynamic
form.errors.prefs['user-99'] // ValidationError[] (empty when no errors)
The key schema constrains what's valid; the value schema validates each entry:
// String keys are the common case
z.record(z.string(), z.boolean())
// Constrained keys
z.record(z.enum(['admin', 'editor', 'viewer']), z.boolean())
// Number-valued
z.record(z.string(), z.number().min(0).max(100))
form.values.prefs[key] reads boolean | undefined; the | undefined comes from noUncheckedIndexedAccess, the same way array index reads do. Reach for ?? defaults at the call site:
const checked = form.values.prefs[userId] ?? false
Mutating records
Records don't expose field-array helpers (append / remove / etc.); they're keyed dictionaries, not ordered sequences. Mutate them via setValue directly:
form.setValue(`prefs.${userId}`, true) // set / overwrite one entry
form.setValue('prefs', { ...form.values.prefs, [userId]: true }) // whole-record merge
To flag a record entry blank, reach for unset:
import { unset } from 'attaform/zod'
form.setValue(`prefs.${userId}`, unset)
unset at a record entry writes the value schema's slim default at the path and adds the path to form.blankPaths. The bound input renders empty, and a required value schema surfaces atta:no-value-supplied reactively. To clear the whole record back to {}, pass unset at the record container: form.setValue('prefs', unset).
z.map(keySchema, valueSchema)
Map is the primitive Map<K, V>, distinct from records (which are plain JS objects):
const schema = z.object({
scoresByUser: z.map(z.string(), z.number()),
})
const form = useForm({
schema,
defaultValues: { scoresByUser: new Map() },
})
form.values.scoresByUser // Map<string, number>
form.values.scoresByUser.get('user-42') // number | undefined
The runtime treats Map as a leaf container; form.values.scoresByUser returns the live Map, and you call its methods directly. Use map (over record) when:
- You need
Map-specific semantics: insertion order, key types beyond strings, or.sizeas an O(1) read. - The form persists to
'indexeddb'and you want structured-clone fidelity.JSON.stringifyflattens aMapto{}; structured clone preserves it.
Records are the right call for serialization-friendly dictionaries; maps are right when you need the primitive.
Iterating in templates
For records, iterate over Object.entries:
<template>
<label v-for="[userId, enabled] in Object.entries(form.values.prefs)" :key="userId">
<input v-register="form.register(`prefs.${userId}`)" type="checkbox" :checked="enabled" />
{{ userId }}
</label>
</template>
For maps, iterate the Map directly:
<template>
<div v-for="[userId, score] in form.values.scoresByUser" :key="userId" class="row">
<span>{{ userId }}</span>
<input v-register="form.register(`scoresByUser.${userId}`)" type="number" />
</div>
</template>
The template renders re-run when the underlying record / map updates because form.values proxies through the reactivity layer.
Errors per entry
Errors land at the keyed path, the same as array elements:
form.errors.prefs['user-42'] // ValidationError[] (empty when no errors)
form.errors.scoresByUser['user-99'] // (works for maps too)
The aggregate form.meta.errors flattens every entry's errors into one list, in path order.
When to pick which
z.record(z.string(), V): string-keyed dictionaries serialized as JSON. The default choice.z.record(z.enum([...]), V): keys constrained to a small set. Compile-time autocomplete on the keys.z.map(K, V): when you needMap's insertion order, non-string keys, or structured-clone fidelity for IndexedDB persistence.z.object({ … }): when the keys are fixed and known at schema-write time. Records are for the dynamic case.
Where to next
- Arrays & tuples: numeric-keyed sequences; the other half of the "many-items" picture.
- Nested objects: fixed-shape composition; the alternative when keys are known.
unset, the blank-anywhere sentinel: how to flag a single record entry blank, or wipe the whole record back to{}.