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.
| Schema | Derived 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.
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.
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 "".
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.
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.
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.
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”.
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.
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>
))}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.
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).
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.
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 [].
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.
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.
password: z.string().min(8).meta({ input: 'password' }),A password field never sends what was typed back after a failed submission.
Note
zod/mini has no .meta(); add it with .check(z.meta(…)).
password: z.string().check(z.minLength(8), z.meta({ input: 'password' })),