attaform/zod-v4
Explicit Zod v4 adapter subpath. Use this when you want to pin v4 regardless of what your bundler resolves — handy for non-Vite bundlers (webpack, esbuild standalone, Rollup) where you'd otherwise pay for both adapters via the unified attaform/zod entry's runtime fallback.
Most Vite consumers should import from attaform/zod instead — the attaform/vite plugin rewrites that import to this subpath at build time when zod@^4 is detected, so the same lean bundle ships with less ceremony.
Requires zod@^4. Importing this subpath against zod@3 throws a clear version-mismatch error from the adapter at the first schema parse.
import { useForm, zodAdapter, fieldMeta, withMeta, kindOf, assertZodVersion } from 'attaform/zod-v4'
import type { FieldMetaPayload, ZodKind } from 'attaform/zod-v4'
useForm<Schema>(options)
The primary entry point. Returns a typed reactive surface; see The useForm return value.
const schema = z.object({ email: z.email() })
const form = useForm({ schema, key: 'signup' })
Options:
| Field | Type | Required | Description |
|---|---|---|---|
schema | z.ZodType | yes | The Zod schema describing the form shape. |
key | string | no | Form identity for injectForm(key), shared state, persistence keys, or DevTools labels. See Keys. |
defaultValues | DeepPartial<DefaultValuesShape<Form>> | no | Constraints applied over schema defaults. Each leaf may be the unset sentinel. See Default values. |
strict | boolean | no | Default true — defaults that fail the schema seed schemaErrors at construction. false opts out (multi-step wizards, placeholder rows). |
onInvalidSubmit | 'none' | 'focus-first-error' | 'scroll-to-first-error' | 'both' | no | Behaviour on failed submit. See recipe. |
validateOn | 'change' | 'blur' | 'submit' | no | When per-field validation runs. Default 'change'. See recipe. |
debounceMs | number | no | Wait after the last write before re-validating. Default 0. Only valid with validateOn: 'change'. |
coerce | boolean | CoercionRegistry | no | DOM-input coercion. Default true (string→number, string→boolean). See recipe. |
rememberVariants | boolean | no | Default true — switching back to a discriminated-union variant restores its prior subtree. See recipe. |
persist | FormStorageKind | FormStorage | { storage, key?, debounceMs?, include?, clearOnSubmitSuccess? } | no | Persistence config. Per-field opt-in lives at register(). See Persistence config. |
history | true | { max?: number } | no | Enable undo/redo. See recipe. |
Keys
Omit for one-off forms — the runtime allocates a synthetic
__atta:anon:<id> via useId(). Pass a string when you need
cross-component lookup via injectForm(key), shared state across
call-sites, a stable persist storage-key default, or a
recognisable DevTools label.
Keys starting with __atta: are reserved for the library's
internal synthetic-key namespace; passing one throws
ReservedFormKeyError.
Default values
Refinement-invalid leaves that satisfy the slim primitive type at
their path (e.g. 'teal' against z.enum(['red','green','blue']),
a 4-character string against z.string().min(8)) pass through
unchanged so SSR / autosave rehydration can land partial-but-saved
state as-is. Wrong-primitive leaves (a number where a string is
expected) are still replaced by the schema default.
Each primitive leaf may be the unset sentinel to mark the path
displayed-empty at construction.
Persistence config
The form-level option is operational only — backend, key,
debounce, error inclusion. Per-field opt-in lives at every
register('foo', { persist: true }) call site; this config alone
never causes any field to persist.
Three input forms: a string shorthand ('local' / 'session' /
'indexeddb'), a custom FormStorage adapter passed directly, or
the full options bag.
Storage keys carry the schema's fingerprint
(${base}:${fingerprint}) so schema changes auto-invalidate old
drafts. The orphan-cleanup pass on mount sweeps stale-fingerprint
entries on the configured backend AND wipes any matching keys on
the non-configured standard backends. Malformed payloads are wiped
on read.
See persistence recipe for the full pattern.
Schema-attached metadata
Attach labels, descriptions, and placeholders directly to schema
fields. Read them off form.fields(path).label / .description /
.placeholder / .meta — same surface for leaves and containers.
import { z } from 'zod'
import { fieldMeta, withMeta } from 'attaform/zod-v4'
// Native Zod 4 chain
const A = z.object({
email: z.email().register(fieldMeta, {
label: 'Email',
placeholder: 'you@example.com',
}),
})
// Helper (works on v3 and v4)
const B = z.object({
email: withMeta(z.email(), {
label: 'Email',
placeholder: 'you@example.com',
}),
})
Both forms write to the same fieldMeta registry; pick whichever
reads naturally. Container schemas register the same way:
z.object({...}).register(fieldMeta, { label: 'Pickup address' }).
Resolution order for each field on form.fields(path):
| Field | Sources |
|---|---|
label | registered label → humanize(lastSegment) (camelCase / snake_case / kebab-case → Title Case; numeric segments collapse to '') |
description | registered description → schema.description (Zod's .describe(...)) → undefined |
placeholder | registered placeholder → undefined |
meta | the full registered payload, frozen — empty object when nothing has been registered |
Setting both .describe('...') and .register(fieldMeta, { description: '...' }) is fine — the registered description wins,
and .describe() stays readable for unrelated tooling (JSON-Schema
export, etc.).
Custom payload keys. FieldMetaPayload is an interface — extend
it via TypeScript module augmentation:
declare module 'attaform/zod-v4' {
interface FieldMetaPayload {
tooltip?: string
icon?: string
}
}
const schema = z.object({
email: z.email().register(fieldMeta, { label: 'Email', tooltip: 'For login' }),
})
// template: {{ form.fields.email.meta.tooltip }} → 'For login'
The single fieldMeta registry holds the augmented shape — no
fragmentation across consumers.
Reusing a sub-schema at multiple paths. Both registration styles are safe. Zod's registry keys on the schema reference, but the adapter walks the form's schema tree at first lookup and pairs each path with its intended payload — registration order (object literal left-to-right) matches walk order, so the two patterns below both resolve as you'd expect:
const addressSchema = z.object({ line1: z.string(), city: z.string() })
// Native chain — adapter disambiguates by tree-walk order
const form = z.object({
pickup: addressSchema.register(fieldMeta, { label: 'Pickup address' }),
delivery: addressSchema.register(fieldMeta, { label: 'Delivery address' }),
})
// form.fields('pickup').label → 'Pickup address'
// form.fields('delivery').label → 'Delivery address'
// Helper — clones the schema first so each call gets distinct identity
const form2 = z.object({
pickup: withMeta(addressSchema, { label: 'Pickup address' }),
delivery: withMeta(addressSchema, { label: 'Delivery address' }),
})
Sharing a schema with IDENTICAL metadata is also fine — common for
array elements (every line item shares one lineItemSchema instance
and inherits the same per-leaf labels).
zodAdapter(schema)
Lower-level. Returns an AbstractSchema<Form, Form> that wraps a
Zod schema. Reach for it only when composing your own useForm-like
hook.
kindOf(schema)
Returns the zod kind ('string', 'number', 'object',
'discriminated-union', etc.) for a Zod 4 schema. For advanced
adapter work — wrapping register, building per-kind UI, branching
on schema shape.
assertZodVersion(schema)
Throws if the installed zod major doesn't match the adapter. Use
when wiring custom adapter code that introspects schema internals
and would silently misbehave under the wrong version.
type ZodKind
Union of the strings returned by kindOf — 'string' | 'number' | 'boolean' | 'object' | 'array' | 'tuple' | 'discriminated-union' | 'union' | ….
See also
attaform/zod— unified entry that auto-detects the consumer's Zod major.attaform/zod-v3— explicit Zod 3 subpath.