@k8ordo/form

Fields

Every leaf of the schema becomes one control. This page is the map from zod to HTML: what field() returns, which attributes each type derives, how nested objects, repeated rows, checkbox groups and files are named, and which schemas formFields refuses.

What formFields returns

The result of formFields(schema) is JSON in four parts, and useForm takes all of it. A defineForm definition can be passed in place of the schema.

  • fields — a DerivedField per path: input (the attributes to spread), messages (the wording per ValidityState flag, a ValidityFlag) and secret (whether the value must never be echoed).
  • arrays — a DerivedArray per repeated group: path, minItems, maxItems, and item, which describes the fields of one row.
  • rules — the cross-field rules declared with defineForm, as plain data.
  • dropped — the checks that could not become attributes and therefore do not run in the browser.

For example, a title of z.string().min(1, …).max(120, …) becomes this data.

{
  "input": {
    "name": "title",
    "required": true,
    "type": "text",
    "minLength": 1,
    "maxLength": 120
  },
  "messages": {
    "valueMissing": "Enter a title",
    "tooShort": "Enter a title",
    "tooLong": "Use 120 characters or fewer"
  },
  "secret": false
}

What field(path) returns

form.field(path) returns a FieldView: the derived attributes with the form's current state laid over them.

MemberTypeMeaning
inputFieldInputThe attributes to spread onto the control. name is always there; the rest are the constraint attributes derived from the schema. Whenever state carries values (any result parseForm returned), the echoed value is added as defaultValue (defaultChecked for a checkbox).
errorstring | undefinedThe message to show: the browser-side message when there is one, otherwise the server's error until the field is edited.
invalidbooleanerror !== undefined, for aria-invalid and styling.
requiredbooleanThe derived required, for a required marker on the label.

In a form that edits an existing record, write your own defaultValue before the input spread. Until state carries values, input has no defaultValue, so yours is used; once a parseForm result comes back, the echoed value overrides it.

// src/routes/talks/[id]/edit/_parts/edit-talk-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { updateTalk } from './actions';

type Props = {
  fields: FormFields<'title', never>;
  talk: { title: string };
};

export function EditTalkForm({ fields, talk }: Props) {
  const [state, formAction] = useActionState(updateTalk, {});
  const form = useForm(fields, state);
  const title = form.field('title');

  return (
    <form {...form.props} action={formAction}>
      <label>
        Title
        <input defaultValue={talk.title} {...title.input} />
      </label>
      {title.error !== undefined && <p>{title.error}</p>}
      <button type="submit">Save</button>
    </form>
  );
}

A path the schema does not have fails to compile, and if one gets past the types anyway, field() throws at runtime.

From zod types to attributes

The conversion pairs z.toJSONSchema's output with zod's own checks. The table shows what lands in input, leaving out name.

SchemainputNotes
z.string()type: 'text'Accepts '', so no required.
z.string().min(1).max(120)type: 'text', required: true, minLength: 1, maxLength: 120.length(4) becomes both minLength and maxLength.
z.email()type: 'email', required: truezod's email regex does not compile under the browser's v flag, so it is not emitted as pattern and is listed in dropped. In the browser, only the type="email" check runs.
z.url()type: 'url', required: trueThe browser runs its type="url" check.
z.string().regex(/^[a-z]+$/u)type: 'text', required: true, pattern: '^[a-z]+$'A regex becomes pattern only when it is wrapped in ^…$, has no flags or only u, and compiles under the v flag. Anything else is listed in dropped.
z.email().regex(/^[a-z@.]+$/u)type: 'email', required: trueA check stacked on a format keeps the format's type. The stacked regex becomes pattern only when it is the one regex the browser would run (z.url().lowercase()); when the format carries one too, it is listed in dropped.
z.iso.date()type: 'date', required: trueThe date regex is not emitted; type="date" constrains the value more tightly.
z.iso.time()type: 'time', required: truetype="time" ignores pattern, so the regex is listed in dropped.
z.iso.datetime({ local: true })type: 'datetime-local', required: trueA schema that accepts what datetime-local submits: no timezone.
z.iso.datetime()type: 'text', required: true, pattern: '^(…)$'It demands a timezone, which datetime-local cannot submit, so it falls back to type="text", is listed in dropped, and carries its datetime regex as pattern.
z.uuid()type: 'text', required: true, pattern: '^(…)$'A format with no type of its own (z.uuid(), z.ipv4(), z.iso.duration(), …) becomes a text input, and its regex becomes pattern only when the browser reads it the same way.
z.coerce.number()type: 'number', step: 'any', required: trueAn empty numeric field arrives as nothing entered, not as 0, so it is required.
z.coerce.number().int().min(1).max(10)type: 'number', step: 1, min: 1, max: 10, required: trueThe safe-integer range .int() carries is not emitted. On an integer, .positive() or .gt(0) becomes min: 1; on a float, .gt() / .lt() are exclusive bounds HTML cannot express and are listed in dropped.
z.coerce.number().multipleOf(0.5)type: 'number', step: 0.5, required: true.multipleOf() becomes step.
z.coerce.number().optional()type: 'number', step: 'any'Nothing entered is accepted, so no required. .default(5) is the same.
z.boolean()type: 'checkbox'An unchecked box arrives as false, which z.boolean() accepts.
z.literal(true, '…')type: 'checkbox', required: trueA consent box. It rejects an unchecked box, so it is required, in zod's wording.
z.enum(['free', 'team'])required: trueNo type: render it as a <select> or a radio group.
z.enum(['free', 'team']).optional(){}Nothing chosen is accepted, so no required. .default() is the same.
z.array(z.enum(['a', 'b'])){}A checkbox group that carries only its name. .min() / .max() are listed in dropped.
z.file().mime(['image/png'])type: 'file', required: true, accept: 'image/png'.mime() becomes accept, which only narrows the file picker — the browser never checks the type — so it is listed in dropped too. Size bounds from .min() / .max() are listed in dropped.
z.file().optional()type: 'file'An unfilled file input arrives as nothing entered, so with .optional() there is no required.
z.string().min(8).meta({ input: 'password' })type: 'password', required: true, minLength: 8Marked secret: true; the value is never echoed.
z.string().min(2).nullable()type: 'text', required: trueThere is no telling which branch to check, so the constraints inside do not become attributes and are listed in dropped. A union is the same.
z.coerce.date()type: 'text', required: trueThey read strings, so they are kept, but no constraint can be read from them and they are listed in dropped. An empty z.coerce.bigint() field arrives as nothing entered, like a number, so it never becomes 0n, and it is required when the schema rejects nothing entered.
z.coerce.bigint()type: 'text', required: trueThey read strings, so they are kept, but no constraint can be read from them and they are listed in dropped. An empty z.coerce.bigint() field arrives as nothing entered, like a number, so it never becomes 0n, and it is required when the schema rejects nothing entered.
z.string().transform(…)type: 'text'Nothing can be read from the schema, so only type="text" is emitted and the field is listed in dropped. The checks run on the server only.
z.custom<string>(…)type: 'text'Nothing can be read from the schema, so only type="text" is emitted and the field is listed in dropped. The checks run on the server only.

Where required comes from

JSON Schema's required means “the key is present”, but a form submits something for every control. What that is depends on the control, so formFields hands that empty submission to the schema and emits required only when the schema rejects it. parseForm hands the schema exactly the same value, so the browser and the server agree.

ControlWhat the schema receives when it is left untouched
Text-like (text, email, url, date, …)''
Checkboxfalse (true when checked, whatever its value)
Number (a z.coerce.bigint() text field included)undefined (an empty numeric field and an unnamed zero-byte file both mean nothing entered)
Fileundefined (an empty numeric field and an unnamed zero-byte file both mean nothing entered)
<select> / radio groupundefined: the '' a <select> placeholder submits and a radio group with nothing selected, which submits nothing, both mean nothing chosen (a validation error unless the enum accepts undefined).
Checkbox group[]

.optional() does not make a text field optional. A text field submits '', not undefined, so z.string().min(1).optional() stays required, and the server rejects the empty field too. To allow an empty field, write a schema that accepts '', such as z.string().max(200).

Messages come from zod

messages is collected by running the schema against values chosen to fail one check each and keeping the wording zod returns. The text next to a field is exactly what zod itself produces, and is the same text parseForm uses for that check on the server.

To change the wording, write it into the check, as in .min(1, '…'), or load a zod locale with z.config(z.locales.ja()). Either way, what is in effect when formFields runs is what the fields carry.

The browser's own wording is never used: it depends on the browser's locale and cannot be controlled. A flag zod gave no message for shows nothing on the client, and the server decides.

Nested objects

A nested field is addressed by its dotted path, which is also the name the browser submits. Its error is keyed by the same address.city.

// src/routes/profile/_parts/profile-schema.ts
import * as z from 'zod';

export const profileSchema = z.object({
  name: z.string().min(1, 'Enter your name'),
  address: z.object({
    city: z.string().min(1, 'Enter a city'),
    postalCode: z
      .string()
      .regex(/^[0-9]{3}-[0-9]{4}$/u, 'Use the 123-4567 format'),
  }),
});
// src/routes/profile/_parts/profile-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { saveProfile } from './actions';

type Props = {
  fields: FormFields<'name' | 'address.city' | 'address.postalCode', never>;
};

export function ProfileForm({ fields }: Props) {
  const [state, formAction] = useActionState(saveProfile, {});
  const form = useForm(fields, state);
  const name = form.field('name');
  const city = form.field('address.city');
  const postalCode = form.field('address.postalCode');

  return (
    <form {...form.props} action={formAction}>
      <input {...name.input} aria-label="Name" />
      {name.error !== undefined && <p>{name.error}</p>}
      <input {...city.input} aria-label="City" />
      {city.error !== undefined && <p>{city.error}</p>}
      <input {...postalCode.input} aria-label="Postal code" />
      {postalCode.error !== undefined && <p>{postalCode.error}</p>}
      <button type="submit">Save</button>
    </form>
  );
}

An object behind .optional() or .default() keeps its fields. Its controls must still be rendered: when a name never arrives, parseForm throws it as a wiring mistake.

Repeated rows: array(path)

An array of objects is reached through form.array(path), which returns an ArrayView. React state holds only the identity of each row; the values stay in the DOM, so adding or removing a row never copies anything into React.

MemberTypeMeaning
rowsRowView[]The rows. Use key as the React key; index is the row's current position. field(itemKey) returns a field inside the row, and remove() removes the row. itemKey is a plain string, not checked at compile time: a key the row does not have throws when the row renders.
add() => voidAppends a row.
canAddbooleanfalse once the row count reaches the schema's .max().
canRemovebooleanfalse while the row count is at or below the schema's .min().
errorstring | undefinedThe server's error about the array itself, such as a failed .min(1, …).
// src/routes/orders/new/_parts/order-schema.ts
import * as z from 'zod';

export const orderSchema = z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1, 'Enter an item'),
        quantity: z.coerce
          .number('Enter a quantity')
          .int('Use a whole number')
          .min(1, 'Order at least one'),
      }),
    )
    .min(1, 'Add at least one item')
    .max(10),
});
// src/routes/orders/new/_parts/order-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { placeOrder } from './actions';

type Props = {
  fields: FormFields<never, 'items'>;
};

export function OrderForm({ fields }: Props) {
  const [state, formAction] = useActionState(placeOrder, {});
  const form = useForm(fields, state);
  const items = form.array('items');

  return (
    <form {...form.props} action={formAction}>
      {items.rows.map((row) => {
        const name = row.field('name');
        const quantity = row.field('quantity');
        return (
          <fieldset key={row.key}>
            <legend>{`Item ${String(row.index + 1)}`}</legend>
            <input {...name.input} aria-label="Item" />
            {name.error !== undefined && <p>{name.error}</p>}
            <input {...quantity.input} aria-label="Quantity" />
            {quantity.error !== undefined && <p>{quantity.error}</p>}
            {items.canRemove && (
              <button onClick={row.remove} type="button">
                Remove
              </button>
            )}
          </fieldset>
        );
      })}
      {items.error !== undefined && <p>{items.error}</p>}
      {items.canAdd && (
        <button onClick={items.add} type="button">
          Add an item
        </button>
      )}
      <button type="submit">Order</button>
    </form>
  );
}
  • The first render has as many rows as state.rows says after a submission, otherwise .min(), otherwise none. That is also the row count without JavaScript, where the add and remove buttons do nothing.
  • A row field's name carries its index, as in items[0].name, and so does its error key. Removing a row shifts the later rows up; the browser-side messages move with their rows, and the values, being in the DOM, stay put. Server errors in state.errors are not renumbered: after a row above is removed, an unedited server error shows on whichever row now has that index.
  • parseForm counts rows from the highest index submitted and expects every row up to it. The count is capped at .max() (or 1000 without one), so a forged huge index cannot make the server allocate.
  • For an array of scalars such as z.array(z.string()), the row's field has no key: call row.field() with no argument, and its name is tags[0].
  • A repeat inside a repeat has no unambiguous name, so formFields and parseForm throw on it. The types let such a schema through, so it surfaces when the fields are derived.

Choices: <select> and radio groups

z.enum() derives an input with no type. A <select> takes the spread as it is.

Do not spread onto radio buttons; pass name and required. Once state carries values, input carries the echoed value as defaultValue, which collides with each button's own value. Restore the selection from state.values through defaultChecked.

// src/routes/signup/_parts/plan-schema.ts
import * as z from 'zod';

export const planSchema = z.object({
  region: z.enum(['asia', 'europe'], 'Choose a region'),
  plan: z.enum(['free', 'team'], 'Choose a plan'),
});
// src/routes/signup/_parts/plan-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { choosePlan } from './actions';

const PLANS = ['free', 'team'] as const;

type Props = {
  fields: FormFields<'region' | 'plan', never>;
};

export function PlanForm({ fields }: Props) {
  const [state, formAction] = useActionState(choosePlan, {});
  const form = useForm(fields, state);
  const region = form.field('region');
  const plan = form.field('plan');

  return (
    <form {...form.props} action={formAction}>
      <select {...region.input} aria-label="Region">
        <option value="">Choose a region</option>
        <option value="asia">Asia</option>
        <option value="europe">Europe</option>
      </select>
      {region.error !== undefined && <p>{region.error}</p>}

      <fieldset>
        <legend>Plan</legend>
        {PLANS.map((option) => (
          <label key={option}>
            <input
              defaultChecked={state.values?.plan === option}
              name={plan.input.name}
              required={plan.input.required}
              type="radio"
              value={option}
            />
            {option}
          </label>
        ))}
      </fieldset>
      {plan.error !== undefined && <p>{plan.error}</p>}

      <button type="submit">Continue</button>
    </form>
  );
}

With an <option value=""> placeholder, an unpicked <select> submits '', which reaches the schema as nothing chosen (undefined). The enum rejects it, so the field is required: without JavaScript the browser stops the submission; with JavaScript, leaving the unpicked select reports valueMissing in the schema's wording, and parseForm rejects it on the server.

An .optional() or .default() enum accepts nothing chosen, so it is not required. A <select> left on its placeholder and a radio group with nothing selected both pass the server (as the default value, for .default()).

Checkboxes and checkbox groups

z.boolean() is a single checkbox. It arrives as true when checked and false when not, so its value attribute does not matter. To reject an unchecked box, as for consent, write z.literal(true, '…').

An array of enums is a fixed option set the person picks several of: a checkbox group. Every box shares one name, so it is one field reached through field(), not repeated rows.

// src/routes/signup/_parts/preferences-schema.ts
import * as z from 'zod';

export const preferencesSchema = z.object({
  newsletter: z.boolean(),
  terms: z.literal(true, 'Agree to the terms to continue'),
  topics: z
    .array(z.enum(['react', 'css', 'a11y']))
    .min(2, 'Pick at least two topics'),
});
  • parseForm reads every checked box under the shared name. None checked is [], never a wiring error.
  • .min(2) cannot become an HTML attribute (on a group, required would mean “check every box”). It is listed in dropped; declare minChecked to run the same bound in the browser. Declaring rules is covered on the Validation page.
  • Give each box only name. The echo in state.values is an array however many boxes were checked — ['a'] for one, [] for none — so each box's defaultChecked is read from it.
// src/routes/signup/_parts/preferences-form.tsx
'use client';

import { useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState } from 'react';

import { savePreferences } from './actions';

const TOPICS = ['react', 'css', 'a11y'] as const;

type Props = {
  fields: FormFields<'newsletter' | 'terms' | 'topics', never>;
};

export function PreferencesForm({ fields }: Props) {
  const [state, formAction] = useActionState(savePreferences, {});
  const form = useForm(fields, state);
  const newsletter = form.field('newsletter');
  const terms = form.field('terms');
  const topics = form.field('topics');
  const echoed = state.values?.topics;
  const checked = Array.isArray(echoed) ? echoed : [];

  return (
    <form {...form.props} action={formAction}>
      <label>
        <input {...newsletter.input} />
        Send me the newsletter
      </label>

      <label>
        <input {...terms.input} />
        I agree to the terms
      </label>
      {terms.error !== undefined && <p>{terms.error}</p>}

      <fieldset>
        <legend>Topics</legend>
        {TOPICS.map((option) => (
          <label key={option}>
            <input
              defaultChecked={checked.includes(option)}
              name={topics.input.name}
              type="checkbox"
              value={option}
            />
            {option}
          </label>
        ))}
      </fieldset>
      {topics.error !== undefined && <p>{topics.error}</p>}

      <button type="submit">Save</button>
    </form>
  );
}

Files

z.file() derives type="file", and .mime([…]) becomes accept, which only narrows the file picker: only the server checks the type, and dropped says so.

// src/routes/profile/_parts/avatar-schema.ts
import * as z from 'zod';

export const avatarSchema = z.object({
  avatar: z
    .file('Choose an image')
    .mime(['image/png', 'image/jpeg'])
    .max(1_000_000, 'Use an image of 1 MB or less'),
});
  • An unfilled file input still submits: an unnamed, zero-byte file, which z.file() would accept as a real upload. parseForm passes it on as nothing entered, so required keeps meaning what it says.
  • Size bounds from .min() / .max() are byte counts, and no HTML attribute carries them: they run on the server only and are listed in dropped. A file is never echoed into state.values, since no browser lets a value be put back into a file control.
  • Several files in one control are not supported yet: z.array(z.file()) derives repeated single-file rows.

Passwords

parseForm echoes the submitted values for a retry, except the fields marked as passwords. A marked field derives type="password" and secret: true. With zod, mark it with .meta({ input: 'password' }). zod/mini has no .meta() method: pass z.meta({ input: 'password' }) to .check(), or add the schema to z.globalRegistry, which works with either entry.

import * as z from 'zod';

export const loginSchema = z.object({
  email: z.email('Enter your email address'),
  password: z
    .string()
    .min(8, 'Use at least 8 characters')
    .meta({ input: 'password' }),
});
import * as z from 'zod/mini';

export const loginSchema = z.object({
  email: z.email('Enter your email address'),
  password: z
    .string()
    .check(
      z.minLength(8, 'Use at least 8 characters'),
      z.meta({ input: 'password' }),
    ),
});

Components that render no input: HiddenValue

A rich text editor or a third-party combobox renders no <input name>, so FormData never sees its value. Rather than pulling the value out of React state at submit time, park it in a hidden input. The value is an ordinary form entry, so it goes out with the rest of the FormData and needs no submit-time code.

// src/routes/posts/new/_parts/post-form.tsx
'use client';

import { HiddenValue, useForm } from '@k8ordo/form';
import type { FormFields } from '@k8ordo/form';
import { useActionState, useState } from 'react';

import { createPost } from './actions';
import { RichTextEditor } from './rich-text-editor';

type Props = {
  fields: FormFields<'title' | 'body', never>;
};

export function PostForm({ fields }: Props) {
  const [state, formAction] = useActionState(createPost, {});
  const form = useForm(fields, state);
  const [body, setBody] = useState('');
  const title = form.field('title');
  const bodyField = form.field('body');

  return (
    <form {...form.props} action={formAction}>
      <input {...title.input} aria-label="Title" />
      {title.error !== undefined && <p>{title.error}</p>}
      <RichTextEditor onChange={setBody} value={body} />
      <HiddenValue name="body" value={body} />
      {bodyField.error !== undefined && <p>{bodyField.error}</p>}
      <button type="submit">Publish</button>
    </form>
  );
}

It is a component rather than a props helper because React updates a controlled value without any DOM event. HiddenValue announces each change with an input event, so cross-field rules and isDirty hear it like a keystroke. The value it mounted with is kept as the baseline for isDirty.

A hidden input is exempt from the browser's constraint validation, so this field shows no client-side message. The schema still validates it on the server, and the error reads through field('body').error. A form reset does not restore it either: the value is the caller's state.

Paths are checked at compile time

formFields derives the set of valid paths from the schema's type, so a typo is a build error rather than something you find by clicking. Arrays of objects go to array() and arrays of enums (checkbox groups) to field(), and mixing them up fails to compile too. Cross-field rules are typed against the same paths.

  • form.field('titel') — not a field in the schema.
  • form.field('items') — an array of objects is reached through array().
  • form.array('address') — an object, not an array.
  • form.array('topics') — a checkbox group is one field, reached through field().
  • sameAs('confrim', 'password', …) — rules are typed against the schema's paths too, so the typo fails to compile.

Checks the browser skips: dropped

A check that cannot become an attribute is not discarded in silence: it comes back in dropped as a DroppedCheck ({ field, reason }). It still runs on the server; it does not run in the browser. Outside production the same list is also logged with console.warn, once per schema, so it is seen without anyone remembering to read it.

These land in dropped:

  • Checks such as .refine() on the root object (field is (schema), and only their count is known)
  • Regexes not wrapped in ^…$, carrying a flag other than u, or failing to compile under the v flag (z.email()'s included)
  • Several regexes on one string (the pattern attribute holds one), including a format that carries its own, as in z.email().regex(…)
  • A regex on type="time", which ignores pattern (z.iso.time()'s included), and one stacked on type="date" or datetime-local
  • Exclusive bounds on a float, such as .gt() / .lt()
  • z.iso.datetime(), which demands a timezone
  • Constraints inside .nullable() or a union
  • Fields whose constraints became unreadable behind .transform(), a .pipe() into a type JSON Schema cannot describe, z.custom() and the like
  • The type restriction of .mime() (accept only narrows the file picker)
  • Count bounds on a checkbox group and size bounds on a file

Not listed in dropped yet: a .refine() / .superRefine() on a single field, on a nested object, or on a row (only the root object's checks are counted). It still runs on the server.

To run a cross-field check in the browser as well, declare it as a defineForm rule instead of a .refine(). Declaring rules is covered on the Validation page.

The reason text is written in Japanese.

Schemas refused at derive time

A schema no form can express makes formFields throw with the path and the reason, instead of deriving a form that silently misreads what was typed or can never succeed. parseForm walks the schema the same way and throws the same error.

  • z.number() and z.literal(1) — every value arrives as a string, so no submission could pass. Use z.coerce.number().
  • z.date(), z.bigint() and z.nan() — they accept no string. z.coerce.date() and z.coerce.bigint() read strings and are kept.
  • z.stringbool() — it would derive a checkbox, but parseForm hands a checkbox's schema a boolean, so no submission could pass. Use z.boolean().
  • z.record() — its keys cannot be enumerated.
  • z.tuple() — its elements are not uniform, so it cannot be repeated rows.
  • A repeat inside a repeat (an array of anything but enums inside a repeated row)
  • A .nullable() object or array, and a union containing an object or an array
  • Keys containing ., [ or ] — they collide with the separators in name.

A z.custom() that rejects strings cannot be read, so it cannot be refused: it derives as a text input, is listed in dropped, and fails on the server.