form.values — the storage shape

form.values.<path> answers one question: what does storage hold right now at this path? This page is the mental model that lets you predict the answer for any schema, at compile time and at runtime.

The short version: storage holds the resolved, concrete type at every path. .default() has fired. Preprocess has normalised. Blank required leaves have been filled with the type's falsy. The static type agrees end-to-end — so direct reads (form.values.flag, form.values.address.city.length) just work without ?. chains, casts, or schema re-parses.

The three shapes

A schema produces three different views of its data, each with a distinct surface:

SurfaceShapeWhat it answers
form.values / form.fieldsreadWhat does storage hold now? (ReadShape<Schema>)
setValue / defaultValueswriteWhat may the consumer pass in? (z.input<Schema>)
handleSubmit / form.process()submitWhat does a successful parse yield? (z.output<Schema>)

The same schema produces all three; the surface determines which one you're holding.

const schema = z.object({
  flag: z.boolean().default(true),
  count: z.number().default(0),
  trimmed: z.preprocess((v) => (typeof v === 'string' ? v.trim() : v), 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 // number        ← .default(0) peeled
form.values.trimmed // string        ← preprocess peeled to inner input
form.values.ratio // string        ← transform deferred to parse

// Write — `unknown` for preprocess slots, `undefined` allowed for defaulted
form.setValue('flag', undefined) // OK — default fills the gap
form.setValue('trimmed', '  hi  ') // OK — preprocess normalises at write

// Submit — transforms run, refinements fire
form.handleSubmit((data) => {
  data.ratio // number ← .transform() produced this
})

Per-wrapper read-shape policy

ReadShape<Schema> (the type behind form.values) walks each field in the schema's shape and applies one of these rules:

WrapperField keyField type at the keyRationale
.default(x)requiredinner type (no | undefined)Storage always holds x or a write — never empty.
.prefault(x)requiredinner typeSame as .default(x).
.catch(x)requiredinner typeCatch wraps a fallback; storage holds a value.
.optional()optionalinner | undefinedGenuinely optional — undefined is the wrapper's marker.
.nullable()requiredinner | nullnull is the wrapper's "explicit empty".
.readonly()requiredinner typeRead-only is type-only; the read shape is its inner.
z.preprocess(fn, T)requiredinner-T input shape (not unknown)Preprocess normalises at the write boundary; storage holds the post-preprocess inner-input value.
.transform(fn)requiredsource input shapeTransforms run at parse, not read — storage holds the pre-transform value.
(plain / fallthrough)requiredz.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 yet aren't undefined — the form library fills them with the type's falsy concrete at mount:

Schema at pathInitial 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 inputs recipe for the storage / display divergence story. Submit / validate raise 'No value supplied' for required blanks; user-typed 0 / '' / false are honoured.

Three edges the invariant doesn't promise to flatten

These read as honest T | undefined / T | null / T | undefined respectively — they're 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 — synthesis doesn't substitute an empty string. Reach for field.blank if you need the storage / display distinction.

.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 lengthT | undefined

const schema = z.object({ tags: z.array(z.string()) })
form.values.tags[0] // string | undefined

The | undefined taint comes from TypeScript's noUncheckedIndexedAccess: true (which this repo and most 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

A quick cheatsheet, mapped to the three shapes above:

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.process() // imperative one-shot parse

Each surface uses the shape that's correct for its purpose. The mental discipline is: don't reach across surfaces. If you want post- transform output, go through submit. If you want the raw user input, go through form.values. If you want to write, go through setValue or clear — the proxy at form.values is read-only on purpose.

Reset vs clear — the orthogonality

The two operations look adjacent but mean different things:

const schema = z.object({
  notify: z.boolean().default(true),
  count: z.number().default(5),
})
const form = useForm({ schema })

form.reset() // notify → true,  count → 5  (declared defaults)
form.clear() // notify → false, count → 0  (falsy-for-type)

reset re-applies the schema's declared .default() values; clear ignores them and writes the type's falsy concrete instead. Both accept a path argument (reset(next?) reseeds the whole form; resetField(path) reseeds one; clear(path?) wipes one or the whole form). The full surface lives in the useForm return reference.