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. (FormData gives strings even for type="number".)
  • Checkboxes — the value string (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 empty File (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("") is 0, Boolean("false") is true.
  • z.preprocess(fn, schema) runs your function first, then the schema. You decide what empty and invalid mean.
What z.coerce does to typical form strings For an empty string, coerce number gives 0 and passes validation, coerce boolean gives false, and coerce date gives an invalid date that fails. For the string abc, coerce number gives NaN and fails with a type message. For the string false, coerce boolean gives true, which is almost never intended. For a date string like 2026-10-01, coerce date parses it as midnight UTC, which can display as the previous day in western time zones. Input string coerce.number coerce.boolean coerce.date "" (empty) 0 (passes!) false Invalid Date (fails) "abc" NaN (fails) true Invalid Date (fails) "false" NaN (fails) true Invalid Date (fails) "2026-10-01" NaN (fails) true midnight UTC The dangerous cells are the ones that pass: an empty required number becomes 0 and "false" becomes true.

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

  1. Decide what “empty” means for each field. For almost every form field, an empty string means “not answered” — undefined — not 0, false or "".
  2. Convert empty to undefined before type checks. Then .optional() does the right thing for optional fields, and required fields fail with a required message rather than passing as zero.
  3. Keep unparsable input as the original string. Returning the string lets the type check fail with your “Enter a number” message; returning NaN gives a less helpful error and loses the input for display.
  4. Parse checkboxes by presence. An unchecked box is absent from FormData; Boolean(undefined) is fine, but Boolean("false") is not — handle strings explicitly.
  5. Keep date-only values as calendar strings. Converting "2026-10-01" to a Date introduces a time zone; validate the string and convert only where an instant is truly needed, as explained in validating dates across time zones.
  6. 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.

A number field through preprocess and pipe The raw string from FormData first goes through empty handling, where an empty or whitespace string becomes undefined and fails the required check with enter the number of guests. Otherwise commas are stripped and the string is parsed; unparsable text stays a string and fails the type check with enter a number. A parsed number then goes through the domain rules in pipe, such as integer and minimum one, each with its own message. Raw: " 3 " From FormData, always a string. Empty or whitespace → undefined → required message. Parse Strip separators; Number(). Unparsable → keep the string → "Enter a number". z.number() Type check. Passes for 3. .pipe(int, min 1, max 12) Domain rules. Each rule has its own message.

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.

Which tool for which field For number fields, use preprocess that maps empty to undefined and keeps unparsable strings, then pipe into domain rules. For checkboxes, preprocess by presence of the on value. For date-only fields, validate the calendar string and avoid converting to a Date. For optional text, map empty to undefined and trim. z.coerce alone is suitable only where empty cannot occur, such as a select with no blank option. Numbers preprocess: "" → undefined. pipe into rules. Checkboxes Presence → true. Absent → false. Dates (date-only) Validate the string. No Date object. Optional text "" → undefined, trim.

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.


Related

← Integrating Zod for Schema Validation