Skip to content

Schema Validation

The second validation mechanism — see Per-node Validation for how it relates to validators. toSchemaValidator() (from @kin-form/validators) adapts any Standard Schema-compliant library (zod v4+, valibot v1+, arktype, ...) into a SchemaValidator that validates a whole group's or form's value in one pass.

Pass it as schemaValidator, not validators — it's a distinct option on FieldApi/FormApi:

ts
const checkoutSchema = z.object({
  email: z.string().email(),
  items: z.array(z.object({ code: z.string() })).min(1),
});

const form = new FormApi({
  initialValue: { email: "", items: [] },
  schemaValidator: toSchemaValidator(checkoutSchema),
});

Reading the result

Running the schema populates schemaErrorMap, a flat dot-joined path -> message map (e.g. { "email": "Invalid email", "items.0.code": "Required" }) built from every issue's path. A field reads its own slice via field.schemaError — no per-field wiring needed, even through intermediate fields, since the lookup walks up parent until it finds a map with an answer:

tsx
{
  field.invalid && field.touched && (
    <span>{field.error ?? field.schemaError}</span>
  );
}

An issue with no path (e.g. a schema-level .refine()) maps to the group's own "" key — read via form.schemaErrorMap?.[""], or form.schemaError, which checks "" first before falling back to a parent's slice.

schemaErrorMap vs. error

schemaErrorMap is kept separate from error (a field's own message from validators). Nothing is overwritten — a field can carry both a hand-written validator's error and a schema's schemaError at once.

Works the same nested or flat

One schema covers the whole subtree, so nested fields don't need intermediate fields just to be addressed — a schema on form validates contact.name or guests.0.email whether or not anything called field("contact") first:

tsx
const form = useForm({
  initialValue: { contact: { name: "", email: "" }, guests: [] },
  schemaValidator: toSchemaValidator(registrationSchema),
});

// Flat — reads form's own schemaErrorMap directly.
<TextField api={form.field("contact.name")} label="Name" />;

// Nested — same schemaError, found by walking up through `contact`.
const contact = form.field("contact");
<TextField api={contact.field("name")} label="Name" />;

Exception: if contact has its own schemaValidator, it takes precedence over form's for everything under it — the nearer validator always wins, so contact.name would read from contact's own map instead. An issue on the array itself (e.g. Zod's .min(1, "Add at least one guest") on guests) has no field at that exact path, so read it off the map directly: form.schemaErrorMap?.guests. See Flat vs. Nested Structure for when nesting is still worth it.

Debouncing

Like asyncValidator, runs are debounced by validationDebounceMs (default 0) — same debounce/coalescing/handleBlur-flush behavior as described here.

Released under the MIT License.
Copyright © 2026-present Man Hoang