Every value that comes out of a form is a string — or a File, or missing — and a schema written for your domain types rejects it: z.number() fails on "42", z.date() fails on "2026-10-01", and the quick fix z.coerce.number() then happily turns an empty field into 0 and the text "abc" into a failed parse with a confusing message.
Coercion is where form validation meets type conversion, and integrating Zod for schema validation depends on getting it right. This page sorts the input shapes forms produce, shows which Zod tool fits each, and builds a small set of reusable field helpers that treat empty as missing and garbage as an error — never as a plausible value.
Context and prerequisites
What arrives from a form, by control:
- Text, email, number, date inputs — strings,
""when empty. (FormDatagives strings even fortype="number".) - Checkboxes — the
valuestring (default"on") when checked, absent when unchecked. - Selects and radios — the chosen option’s value string, or absent if no radio is checked.
- File inputs —
File, with an emptyFile(size 0, name"") when nothing was chosen.
Zod offers two tools:
z.coerce.<type>()applies the JavaScript constructor (Number(x),String(x),Boolean(x),new Date(x)) before validating. Fast and short, but it inherits JavaScript’s conversions:Number("")is0,Boolean("false")istrue.z.preprocess(fn, schema)runs your function first, then the schema. You decide what empty and invalid mean.
The core pattern: small field helpers with explicit empty handling
import { z } from "zod";
// Empty string and whitespace-only mean "the user did not answer".
const emptyToUndefined = (v: unknown) =>
typeof v === "string" && v.trim() === "" ? undefined : v;
/** A number field. Empty → undefined (so .optional() or a required message applies). */
export const formNumber = (opts: { required?: string; invalid?: string } = {}) =>
z.preprocess(
(v) => {
const e = emptyToUndefined(v);
if (typeof e !== "string") return e;
// Accept "1,234.5"? Decide explicitly. Here: strip thousands separators.
const n = Number(e.replace(/,/g, ""));
return Number.isNaN(n) ? e : n; // keep the bad string so the type check fails clearly
},
z.number({
required_error: opts.required ?? "Enter a number.",
invalid_type_error: opts.invalid ?? "Enter a number, like 12 or 12.5.",
}),
);
/** A checkbox: present ("on" or its value) → true, absent → false. */
export const formCheckbox = () =>
z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean());
/** A date-only field: keep the calendar date as a string, validated, never shifted by time zones. */
export const formDate = (msg = "Enter a date, like 2026-10-01.") =>
z.preprocess(emptyToUndefined, z.string({ required_error: msg }).regex(/^\d{4}-\d{2}-\d{2}$/, msg)
.refine((s) => !Number.isNaN(Date.parse(`${s}T00:00:00Z`)), msg));
/** Optional text: empty → undefined, otherwise trimmed. */
export const formText = () => z.preprocess(emptyToUndefined, z.string().trim().optional());
// Usage
export const Booking = z.object({
guests: formNumber({ required: "Enter the number of guests." }).pipe(z.number().int().min(1).max(12)),
checkIn: formDate(),
childFriendly: formCheckbox(),
notes: formText(),
budget: formNumber().optional(), // truly optional number: empty → undefined
});
Step-by-step walkthrough
- Decide what “empty” means for each field. For almost every form field, an empty string means “not answered” —
undefined— not0,falseor"". - Convert empty to
undefinedbefore type checks. Then.optional()does the right thing for optional fields, and required fields fail with a required message rather than passing as zero. - Keep unparsable input as the original string. Returning the string lets the type check fail with your “Enter a number” message; returning
NaNgives a less helpful error and loses the input for display. - Parse checkboxes by presence. An unchecked box is absent from
FormData;Boolean(undefined)is fine, butBoolean("false")is not — handle strings explicitly. - Keep date-only values as calendar strings. Converting
"2026-10-01"to aDateintroduces a time zone; validate the string and convert only where an instant is truly needed, as explained in validating dates across time zones. - Chain domain rules with
.pipe.formNumber().pipe(z.number().int().min(1))separates “is it a number” from “is it an allowed number”, so messages stay specific.
Why coercion belongs at the edge, not in the domain schema
It is tempting to add z.coerce throughout a shared domain schema so it “just works” with forms. That makes the schema lenient everywhere it is used: an API endpoint validating JSON would now accept "42" for a number and "false" as true, and data that should have been rejected slips into the system with the wrong meaning. Keep the domain schema strict — numbers are numbers — and put form-specific coercion in a thin layer that wraps it: FormBooking = Booking.extend({ guests: formNumber().pipe(Booking.shape.guests) }), or a preprocessing step that turns FormData into typed input before the strict parse. The form edge converts; the domain validates.
Failure modes and edge cases
1. Required numbers that pass as zero
z.coerce.number().min(0) accepts an empty field as 0. For quantities, prices and ages this silently submits a value the user never entered. Always handle empty before coercing.
2. Locale decimal separators
"1,5" in German is one and a half; stripping commas turns it into fifteen. If your audience writes decimal commas, parse with the locale’s separators — see locale-aware number and currency inputs — rather than a blanket replace.
3. Multiple values for one name
Checkbox groups and multi-selects send several values under one name. Object.fromEntries(formData) keeps only the last. Build input with formData.getAll(name) for those fields and validate with z.array(z.string()).
4. Empty file inputs
An unfilled file input submits an empty File with size === 0 and name === "". Treat it as missing with a preprocess step: (v) => v instanceof File && v.size === 0 ? undefined : v.
5. Input and output types diverge
With preprocess, the schema’s input type is unknown for those fields while the output is precise. Form libraries that type fields from the input type need the three-generic setup described in wiring React Hook Form to a Zod resolver.
Verification checklist
Frequently Asked Questions
Is z.coerce ever the right choice for forms?
Yes, where the input cannot be empty or ambiguous — a select with no blank option whose values are numeric ids, for example. Anywhere a user can leave the field blank, handle empty explicitly first.
How do I show the user's original text when parsing fails?
Keep the raw string in form state and validate it, rather than replacing the field’s value with the parsed number. The preprocess helper above returns the original string on failure, so error messages and the input both reflect what was typed.
Does this apply to Valibot or Yup?
The same questions apply. Valibot uses pipe with transform actions and has no implicit coercion; Yup casts by default, with the same empty-string pitfalls as z.coerce. Whatever the library, decide empty-to-missing explicitly.