@k8ordo/form

Field types

The schema decides which control each field becomes. This page shows how to write and render each kind.

On this page

At a glance

The input that field() returns carries these attributes. Spread it onto the control as it is.

SchemaDerived control
z.string()type="text"
z.email() / z.url()type="email" / type="url"
z.iso.date() / z.iso.time()type="date" / type="time"
z.iso.datetime({ local: true })type="datetime-local"
z.coerce.number()type="number"
z.boolean() / z.literal(true)type="checkbox"
z.stringbool()type="checkbox" value="true"
z.file()type="file"
z.enum([…])No type. Spread it onto a <select>
z.array(z.enum([…]))No type. One checkbox per option
Anything else (z.uuid() and so on)type="text"

Accept text

z.string() becomes a text field. .min(), .max() and .regex() become minlength, maxlength and pattern.

ts
z.object({
  handle: z.string().min(3).max(20).regex(/^[a-z0-9_]+$/),
});

// handle.input
// { name: 'handle', type: 'text', required: true,
//   minLength: 3, maxLength: 20, pattern: '^[a-z0-9_]+$' }

z.email() and z.url() become type="email" and type="url", and the browser checks the format.

Warning

Pitfall

An empty text field submits "". So even with .optional(), a field with .min(1) does not accept a blank.

To allow a blank, write a schema that accepts "".

ts
bio: z.string().min(1).optional(),
bio: z.string().max(200),

Accept dates and times

The z.iso formats become the browser’s date and time controls. The value arrives as an ISO string.

ts
z.object({
  day: z.iso.date(),
  start: z.iso.time(),
  doors: z.iso.datetime({ local: true }),
});

For a Date, use z.coerce.date(), which derives a text field. z.date() cannot accept a string, so formFields refuses it.

Accept numbers

Write a numeric field with z.coerce.number(). It becomes type="number", and .min() and .max() become min and max.

ts
z.object({
  seats: z.coerce.number().int().min(1).max(500),
  price: z.coerce.number().multipleOf(0.01).optional(),
});

// seats.input → { type: 'number', required: true, step: 1, min: 1, max: 500 }
// price.input → { type: 'number', step: 0.01 }

step is 1 after .int(), the given value after .multipleOf(), and any otherwise, which allows decimals.

An empty numeric field submits nothing; it is not 0. Add .optional() to allow a blank.

Warning

Pitfall

z.number() does not work: every submitted value is a string, so the field could never pass. formFields throws to tell you.

Pick one option

Spread a z.enum() field’s input onto a <select>. Put an option with an empty value first, so that required means “choose one”.

tsx
const format = form.field('format');

<select {...format.input}>
  <option value="">Choose a format</option>
  <option value="talk">Talk</option>
  <option value="workshop">Workshop</option>
</select>

With .optional(), the empty option can be submitted as it is.

Use radio buttons

Give hand-written radio buttons only name and required. Restore the chosen value per option from state.values.

tsx
const format = form.field('format');

{['talk', 'workshop'].map((option) => (
  <label key={option}>
    <input
      defaultChecked={state.values?.format === option}
      name={format.input.name}
      required={format.input.required}
      type="radio"
      value={option}
    />
    {option}
  </label>
))}
Warning

Pitfall

Do not spread input as it is. After a submission it carries the previous choice as defaultValue, which collides with each radio button’s value.

Information

Note

@k8ordo/ui’s Radio and RadioCard take the spread input as it is.

Accept a checkbox

z.boolean() becomes a checkbox. An unchecked box arrives as false, so it is never required.

When the box must be checked, as with agreeing to terms, use z.literal(true).

ts
z.object({
  newsletter: z.boolean(),
  terms: z.literal(true, 'Agree to the terms to continue'),
});

A checkbox that submits a string

A z.stringbool() field submits the string in input.value ("true" by default) when checked. Use it in GET forms whose values stay in the URL.

ts
z.object({
  inStock: z.stringbool().default(false),
});

// inStock.input → { name: 'inStock', type: 'checkbox', value: 'true' }

An unchecked box submits nothing, so add .default(false) or .optional(). .default(true) is refused, since unchecking could never submit false.

Pick several options

z.array(z.enum()) becomes a group of checkboxes sharing one name. The checked ones arrive as an array, and none checked is [].

tsx
const tags = form.field('tags');
const checked = state.values?.tags;

{['react', 'css', 'a11y'].map((option) => (
  <label key={option}>
    <input
      defaultChecked={Array.isArray(checked) && checked.includes(option)}
      name={tags.input.name}
      type="checkbox"
      value={option}
    />
    {option}
  </label>
))}

A lower bound such as .min(2) has no HTML attribute, so the browser does not check it. Declare the minChecked rule to check it there too.

Accept a file

z.file() becomes a file control, and .mime() becomes accept.

ts
z.object({
  slides: z.file().mime(['application/pdf']).max(10_000_000),
});

// slides.input → { name: 'slides', type: 'file', required: true,
//                  accept: 'application/pdf' }

accept only narrows what the file picker offers; the browser never checks the type. Type and size are checked on the server alone.

After a failed submission, the chosen file does not come back: browsers do not let a file control be given a value.

Accept a password

Mark a field with input: "password" in the schema’s metadata, and it becomes a type="password" field.

ts
password: z.string().min(8).meta({ input: 'password' }),

A password field never sends what was typed back after a failed submission.

Information

Note

zod/mini has no .meta(); add it with .check(z.meta(…)).

ts
password: z.string().check(z.minLength(8), z.meta({ input: 'password' })),
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2