Transforming and Coercing Form Input with Zod

Every value that leaves an HTML control arrives as a string, so a schema that expects a number, a Date, or a trimmed identifier must convert before it validates; this page shows how Zod schema validation turns raw FormData strings into typed domain values with z.coerce, .transform(), and .preprocess() while keeping the native submit path intact.

When to Use This Coercion-First Approach

Coercion belongs at the schema boundary — the moment untrusted string input crosses into typed application code. Reach for it when the shape you validate against is not string:

  • Numeric and boolean fields<input type="number"> and checkboxes still yield strings ("42") or undefined; z.coerce.number() and z.coerce.boolean() normalise them.
  • Dates — a <input type="date"> produces "2026-07-24"; z.coerce.date() yields a real Date.
  • Normalising before comparison — trimming, lowercasing an email, or stripping formatting from a phone number so downstream cross-field validation compares clean values.

If your schema only checks that strings match a pattern or length, you do not need coercion — plain z.string() refinements are enough, as covered in Using Zod for Complex Form Schemas. Prefer coercion when the output type must differ from the input string. The diagram below shows the three stages every field passes through.

Coercion pipeline for form input A raw FormData string is coerced to a target type, then validated, producing a typed domain value. One field, three stages from control to domain value Raw string FormData "42" Coerce / transform z.coerce.number() Validated value number 42
Coercion sits between the raw string and validation, so the schema's refinements run against the already-typed value.

Wiring z.coerce into the novalidate Constraint Validation Baseline

The form ships novalidate and drives feedback through a single manual Constraint Validation API pass. Zod parses the coerced FormData; on failure we map each issue back to its control with setCustomValidity() and surface everything with one reportValidity() call.

import { z } from 'zod';

// Output types differ from the raw string inputs: number, Date, boolean.
const SignupSchema = z.object({
  email: z.string().trim().toLowerCase().email('Enter a valid email'),
  age: z.coerce.number().int().min(18, 'Must be 18 or older'),
  startsOn: z.coerce.date().min(new Date(), 'Date must be in the future'),
  // Unchecked checkboxes are absent from FormData → undefined → false.
  newsletter: z.coerce.boolean(),
});

type Signup = z.infer<typeof SignupSchema>;

const form = document.querySelector<HTMLFormElement>('#signup')!;

form.addEventListener('submit', (event) => {
  event.preventDefault();

  // Clear any stale custom messages before re-validating.
  for (const el of form.elements) {
    if (el instanceof HTMLInputElement) el.setCustomValidity('');
  }

  const raw = Object.fromEntries(new FormData(form)); // all values are strings
  const result = SignupSchema.safeParse(raw);

  if (!result.success) {
    for (const issue of result.error.issues) {
      const name = String(issue.path[0]);
      const field = form.elements.namedItem(name);
      if (field instanceof HTMLInputElement) field.setCustomValidity(issue.message);
    }
    form.reportValidity(); // single native pass shows all bubbles
    return;
  }

  const data: Signup = result.data; // { email: string, age: number, startsOn: Date, newsletter: boolean }
  submitSignup(data);
});

declare function submitSignup(data: Signup): void;

Because coercion runs first, age is a real number and startsOn a real Date by the time .min() executes — the constraints compare typed values, not lexical strings.

z.coerce vs .transform() vs .preprocess(): Option Reference

Each mechanism changes a value, but they differ in when they run relative to validation and what they accept.

Option Input → Output Runs Purpose
z.coerce.number() unknownnumber Before the type check Wraps the JS constructor (Number(value)) then validates the result
z.coerce.date() unknownDate Before the type check new Date(value); invalid input yields Invalid Date and fails
z.coerce.boolean() unknownboolean Before the type check JS truthiness — note any non-empty string becomes true
.transform(fn) TU After validation succeeds Reshape a value once it is known valid; cannot report errors alone
.preprocess(fn, schema) unknown → schema input Before validation Custom normalisation (strip commas, coalesce empty string to undefined)
.pipe(schema) T → next schema After the current schema Chain a transform’s output into a second round of validation

Verification Steps

Confirm that the output type, not just the string, is what reaches your handler.

import { test, expect } from '@playwright/test';

test('coerces age to a number and rejects a past date', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('#age', '25');
  await page.fill('#startsOn', '2020-01-01');
  await page.click('button[type="submit"]');
  // Native bubble text comes from the Zod issue message we set.
  const message = await page.locator('#startsOn').evaluate(
    (el: HTMLInputElement) => el.validationMessage,
  );
  expect(message).toBe('Date must be in the future');
});

Edge Cases & Failure Modes

Empty strings coerce to zero, not “missing”. z.coerce.number() turns "" into Number("") === 0, so a blank optional field silently validates as 0. Fix it by pre-empting the empty string: z.preprocess((v) => (v === '' ? undefined : v), z.coerce.number().optional()).

z.coerce.boolean() treats every non-empty string as true. A hidden field carrying "false" becomes true because coercion uses JS truthiness. For string-flag fields, validate the literal instead: z.enum(['true', 'false']).transform((v) => v === 'true').

.transform() runs too late to drive a native message. Because transforms fire only after validation passes, they cannot produce an issue on their own; move the failing condition into a .refine() or superRefine() so the message reaches setCustomValidity(). For layered cross-field rules see Zod superRefine for Cross-Field Rules, and pair the surfaced text with custom validity messages.

Chaining .pipe() for a Two-Stage Numeric Parse

A single z.coerce.number() is a blunt instrument: it accepts anything Number() accepts, including " 12 ", hex literals, and the empty-string-to-zero trap noted above. When a field carries locale formatting — thousands separators, a currency symbol, a trailing percent — you need a normalisation stage that produces a clean string and a validation stage that enforces the numeric contract. .pipe() connects the two: the left schema’s output becomes the right schema’s input, giving each stage a single, testable responsibility.

import { z } from 'zod';

// Stage 1 strips formatting and rejects genuinely empty input.
// Stage 2 validates the cleaned string as a bounded currency amount.
const PriceField = z
  .string()
  .transform((raw) => raw.replace(/[$,\s]/g, '')) // "$1,299.00" → "1299.00"
  .pipe(
    z.coerce
      .number()
      .refine((n) => Number.isFinite(n), 'Enter a valid amount')
      .nonnegative('Price cannot be negative')
      .max(1_000_000, 'Price exceeds the allowed maximum'),
  );

// Reusable across schemas — the pipe boundary is the only coupling.
const ProductSchema = z.object({
  name: z.string().trim().min(1, 'Name is required'),
  price: PriceField, // output type: number
});

type Product = z.infer<typeof ProductSchema>;

The key advantage over a lone .transform() is that the second schema can still fail with a message. A transform that returned NaN would silently propagate a broken value; the piped z.coerce.number().refine(...) converts that same NaN into an issue whose message reaches setCustomValidity(). Keep the stages small — one concern per link — and the chain stays readable when a reviewer needs to trace exactly where "$1,299.00" became 1299.

Coercion Changes in Zod v4

If you are on Zod 4, two behaviours differ from the v3 examples above and are worth pinning down before you ship. First, z.coerce.string() no longer stringifies null and undefined into the literal "null"/"undefined" — those inputs now fail the type check instead of producing a surprising truthy string, which removes a class of silent bugs in optional text fields. Second, the coercion wrappers accept their bounds through the same fluent API but the internal order is unchanged: the constructor (Number, Date, Boolean) still runs before any refinement, so every edge case documented here — empty-string-to-zero, non-empty-string-to-true — persists across both major versions. When migrating, re-run the Verification Steps checklist rather than assuming coercion semantics carried over untouched; the failure modes are identical, but the parse-error surface for null-ish input is not. For the object-level shape work that sits on top of these fields, the patterns in Using Zod for Complex Form Schemas apply unchanged across versions.

Frequently Asked Questions

Does z.coerce.number() run before or after Zod's validation checks?

It runs first. Coercion applies the equivalent of Number(value) to the raw input, and only the coerced result is passed to the type check and any chained refinements like .min() or .int(). That ordering is why a comparison such as .min(18) works numerically rather than lexically.

Why does my unchecked checkbox validate as false instead of erroring?

An unchecked checkbox is omitted from FormData entirely, so the key is undefined. z.coerce.boolean() reads undefined as falsy and returns false, which is usually what you want. If an unchecked box should be an error, drop coercion and use z.literal('on') against the checkbox's submitted value.

Should I use .transform() or .preprocess() to normalise input?

Use .preprocess() when the raw string must change before validation — for example coalescing "" to undefined or stripping thousands separators. Use .transform() when the value is already valid and you only want to reshape the output type. Preprocess feeds validation; transform consumes it.

When should I reach for .pipe() instead of stacking refinements on z.coerce.number()?

Reach for .pipe() when the input needs cleaning that coercion cannot do on its own — stripping currency symbols, commas, or a trailing percent — and you still want the final value validated with real error messages. The left side transforms the raw string, and the right side is a full schema that can fail and report an issue. A bare .transform() would swallow a bad conversion as NaN; piping into z.coerce.number().refine(...) turns that same case into a surfaced validation message.

Does z.coerce.number() mutate the original FormData or the DOM value?

No. Coercion produces a new value on result.data and never writes back to the input element or the FormData object you parsed. The control still holds the user's original string, which is exactly what you want for the native submit path and for re-editing. If you need the normalised text reflected in the field — a trimmed email, say — assign it explicitly after a successful parse rather than relying on Zod to do it.

← Back to Schema-Based Validation with Zod