How values are stored
A single mental model for what
form.valuesreturns. Defaults are pre-resolved, optional / nullable keep their wrappers, and schema-side normalizers (preprocess, coerce, transforms) only fire at parse time. Storage holds the consumer's input verbatim.
- Category
- Conceptual
- Read shape
form.values, form.fields- Write shape
setValue, defaultValues- Submit shape
handleSubmit, form.parse()
The demo below shows the same schema across all three views: what form.values returns at runtime, what setValue accepts, and what handleSubmit's callback sees. The phone field is the giveaway: storage keeps the raw digits the user typed, and the preprocess formatter's output only surfaces inside handleSubmit's callback. Preprocess itself runs every time validation does (each keystroke under validateOn: 'change', each blur under 'blur', only at submit under 'submit'), but its output never lands in storage.
READ: form.values
Concrete types after defaults resolve. Preprocess + transforms have NOT run; storage holds raw input.
{
"flag": true,
"phone": "5551234567",
"ratio": "50"
}SUBMIT: handleSubmit argument
Post-parse output. phone is formatted, ratio is a number.
Submit to populate
The three shapes
A schema produces three different views of its data, each with a distinct surface:
| Surface | Shape | What it answers |
|---|---|---|
form.values / form.fields | read | What does storage hold now? (StorageShape<Schema>) |
setValue / defaultValues | write | What may the consumer pass in? (z.input<Schema>) |
handleSubmit / form.parse() | submit | What does a successful parse yield? (z.output<Schema>) |
The same schema produces all three; the surface determines which one you're holding.
const formatPhone = (v: unknown): unknown => {
if (typeof v !== 'string') return v
const digits = v.replace(/\D/g, '')
return digits.length === 10
? `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`
: v
}
const schema = z.object({
flag: z.boolean().default(true),
count: z.coerce.number(),
phone: z.preprocess(formatPhone, z.string()),
ratio: z.string().transform((v) => Number(v) / 100),
})
const form = useForm({ schema })
// READ: storage holds the concrete, resolved type
form.values.flag // boolean ← .default(true) peeled
form.values.count // unknown ← z.coerce.X() input is unknown
form.values.phone // unknown ← preprocess input is unknown
form.values.ratio // string ← transform deferred to parse
// WRITE: anything goes at preprocess / coerce leaves; defaulted leaves accept undefined
form.setValue('flag', undefined) // OK; default fills the gap
form.setValue('count', '42') // OK; raw string lands in storage as-is
form.setValue('phone', '1231231234') // OK; raw digits land in storage as-is
// SUBMIT: schema-side normalizers fire here
form.handleSubmit((data) => {
data.count // number ← z.coerce.number() converted at parse
data.phone // string ← preprocess formatted at parse
data.ratio // number ← .transform() produced this
})
Two layers of mutation
Two distinct surfaces can change what lands in storage. They sit at different boundaries, and form.values reflects only one of them.
| Layer | Mutates storage? | When |
|---|---|---|
v-register modifiers and register transforms | yes | at the user-input boundary |
z.preprocess / z.coerce.X() / .transform(fn) | no | at parse / submit |
The directive layer owns write-time mutation: v-register.trim strips whitespace as the user blurs, v-register.number casts the DOM string to a number, and register({ transforms: [...] }) runs consumer-supplied functions on every commit. Anything those produce ends up in storage.
Schema-side normalizers do their work later, inside safeParse. The consumer's verbatim write lands in storage; handleSubmit, validate, and validateAsync re-parse storage through the schema and surface the typed result. There is no schema shape that mutates storage at the write boundary.
Per-wrapper read-shape policy
StorageShape<Schema> walks each field and applies one of these rules:
| Wrapper | Field key | Field type at the key | Rationale |
|---|---|---|---|
.default(x) | required | inner type (no | undefined) | Storage always holds x or a write; never empty. |
.prefault(x) | required | inner type | Same as .default(x). |
.catch(x) | required | inner type | Catch wraps a fallback; storage holds a value. |
.optional() | optional | inner | undefined | Genuinely optional; undefined is the wrapper's marker. |
.nullable() | required | inner | null | null is the wrapper's "explicit empty". |
.readonly() | required | inner type | Read-only is type-only; the read shape is its inner. |
z.preprocess(fn, T) | required | unknown | Schema-side normalizer; runs at parse, not at the write boundary. Storage holds raw input. |
z.coerce.X() | required | unknown | Same as z.preprocess: coerce fires inside safeParse. Storage holds whatever the consumer wrote. |
.transform(fn) | required | source input shape | Transforms run at parse, not read; storage holds the pre-transform value. |
| (plain / fallthrough) | required | z.input<T> | Default for anything else. |
Reads at every nested level get the same treatment recursively.
Blank-path synthesis
Required leaves that haven't been written to aren't undefined. The runtime fills them with the type's falsy concrete at mount:
| Schema at path | Initial form.values.<path> |
|---|---|
z.string() | '' |
z.number() | 0 |
z.boolean() | false |
z.bigint() | 0n |
z.date() | new Date(0) |
z.array(...) | [] |
z.set(...) | new Set() |
z.record(...) | {} |
z.object({...}) | recursive: every required property gets its own falsy |
The runtime tracks which paths are still "blank" through the same field-state bit field.blank covers; see the blank field-state bit for the storage / display divergence story.
Three edges the invariant doesn't promise to flatten
These read as honest T | undefined / T | null / T | undefined respectively: documented edges, not bugs.
.optional() (no default): T | undefined
const schema = z.object({ bio: z.string().optional() })
const form = useForm({ schema })
form.values.bio // string | undefined
The wrapper's whole point is "this slot may be absent." Storage respects it.
.nullable(): T | null
const schema = z.object({ ref: z.string().nullable() })
form.values.ref // string | null
null is the wrapper's "explicit empty" signal, distinct from undefined and from ''.
Array element past length: T | undefined
const schema = z.object({ tags: z.array(z.string()) })
form.values.tags[0] // string | undefined
The | undefined taint comes from TypeScript's noUncheckedIndexedAccess (which strict configs set), not from the storage invariant. Iteration (for (const tag of form.values.tags)) keeps the strict string element type; only direct numeric indexing is tainted.
When to reach for which surface
const form = useForm({ schema })
// READ: anywhere you need the current value
form.values.email // primary path
form.fields.email.value // same value, plus per-field state
form.toRef('email') // ref-shaped interop for external composables
// WRITE: anywhere you set a value
form.setValue('email', 'a@b.c') // single path
form.setValue({ email: 'a@b.c' }) // whole-form merge
form.clear('email') // wipe to falsy-for-type
form.reset() // re-seed from declared defaults
// SUBMIT: once on form submission
form.handleSubmit((data) => apiPost(data)) // `data` is the post-transform output
const result = await form.parse() // imperative one-shot parse
Each surface uses the shape that's correct for its purpose. The mental discipline: don't reach across surfaces. If you want post-transform output, go through submit. If you want the raw input, go through form.values. If you want to write, go through setValue or clear; form.values is read-only on purpose.
Where to next
- Defaults from the schema: how
.default()flows into the read shape. - Optional, nullable, defaulted: the three missing-ness modifiers, side by side.
- URL availability check: a worked example threading
z.preprocessand async.refineagainst the storage contract. - The validation lifecycle:
parse()returns the submit shape, parsed.