.md

Per-field validation

The schema is the validator. Chain refinements onto one leaf, or attach cross-field rules to the parent with a target path.

Category
Schema pattern
Single-field
.min · .max · .regex · .email · .refine (per-leaf)
Cross-field
parent.refine(checker, { path })
Errors surface at
errors.<targetPath>

Type into each field to watch its own refinements light up: the username's regex requirement, the password's min length, and the cross-field "passwords must match" check that fires when confirmPassword differs from password. Both single-field and cross-field validators live in the schema; the Two patterns section traces each.

Per-field Validation Demo Open in playground

Two patterns

Attaform reads two Zod patterns for per-field validation, both surfacing errors at the right form.errors.<path>.

Single-field chains

Every Zod primitive accepts a chain of refinements:

z.object({
  username: z
    .string()
    .min(3, 'At least 3 characters')
    .max(20, 'At most 20 characters')
    .regex(/^[a-z0-9_]+$/, 'Lowercase letters, numbers, and underscores only'),
})

Every check in the chain runs on every pass, and each one that fails contributes its own message at errors.username. A value can break several at once: 'A' is both too short and not lowercase, so form.errors.username holds two entries, not one. Declaration order is the order they land in, which makes form.fields.username.firstError the first rule you wrote that this value breaks.

So pick a rendering and stick to it. Bind firstError?.message to show one message at a time and let the user work down the list, or map over form.errors.username to surface every broken rule at once. What you cannot assume is that there is only ever one.

Cross-field refinements

For checks that involve multiple paths, attach .refine to the parent object and use the path option to target where the error lands:

z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords must match',
  path: ['confirmPassword'],
})

The refinement runs against the whole parent value; the path option directs the error to errors.confirmPassword so the UI surfaces it next to the right input. Without path, the error lands at the parent path (the form root in this example).

Custom per-field refinements

For predicates beyond the built-in chain, use .refine at the leaf:

z.string().refine((v) => !v.includes('admin'), {
  message: 'Reserved word, pick another username',
})

The function receives the parsed leaf value; return true to accept, false to reject. The message string surfaces as the error's .message.

Sync vs async

Sync refinements run on every validation pass: keystroke, blur, submit (per the validateOn config). For checks that need a server round-trip (uniqueness probes, slug availability, password-breach lookups), reach for async refinements. Zod's .refine accepts an async predicate, and Attaform awaits it before submit dispatches.

Where to next