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'),
})

Each refinement's error message appears at errors.username. The order matters: refinements stop at the first failure, so .min(3) runs before .regex. Read the field's firstError to get the first failure's message; the full array is available at errors.<path> for surfacing every refinement that fired.

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