Zod 15 🛡️ Coercion — z.coerce for parsing strings into numbers and dates
Coercion is the bridge between the wire and the type system. An HTML form sends "42", not 42. A query string carries "2026-05-05", not a Date. z.coerce wraps a primitive schema so that the input is passed through a JavaScript constructor before validation, converting the string to the target type and then applying the schema’s checks. The inference is that the output type is the primitive (number, Date), while the input type defaults to unknown because the constructor accepts anything .
This chapter covers the three coercion schemas that matter—z.coerce.number(), z.coerce.date(), and z.coerce.string()—the behavioral traps that make them unsafe for optional fields, and the v4 alternatives that handle the edge cases. The sections that follow walk through the mechanics, the blank-versus-zero problem, and the boolean coercion gap that z.stringbool() fills.
Key point: z.coerce runs the input through Number(), new Date(), or String() before validation; this is convenient for form data but dangerous for optional fields because "" coerces to 0 (or Invalid Date) rather than undefined, silently turning a blank answer into a real value.
a. The coercion constructors and the input type
z.coerce.number() passes the input through Number(), then validates the result with the number schema’s checks. z.coerce.date() passes the input through new Date(), then validates the result. z.coerce.string() passes the input through String() .
import { z } from "zod";
// Number coercion
const Age = z.coerce.number().int().positive();
Age.parse("42"); // 42
Age.parse("3.14"); // ZodError: expected int
// Date coercion
const Created = z.coerce.date();
Created.parse("2026-05-05"); // Date object
Created.parse(1746439200000); // Date from epoch millis
// String coercion
const Label = z.coerce.string();
Label.parse(123); // "123"
Label.parse(true); // "true"
The input type of every z.coerce schema defaults to unknown, because the constructor accepts any value. The output type is the primitive. In v4, a generic parameter can narrow the input type when the source is known: z.coerce.number<number>() types the input as number rather than unknown .
type RegularInput = z.input<typeof Age>; // unknown
type AgeOutput = z.output<typeof Age>; // number
const TypedAge = z.coerce.number<number>(); // input: number
The narrowing is useful when the coercion is applied to a value that is already a number in one path and a string in another, and the type system should not force a cast at the call site.
b. The blank-versus-zero problem
The defining hazard of z.coerce.number() is that Number("") is 0. A blank form field—the user cleared the input—arrives as an empty string, and the coercion turns it into a valid zero. If the schema is z.coerce.number().optional(), the empty string never reaches the optional check because the coercion has already produced a number .
// The optional field the user cannot clear
const Schema = z.object({
reservePrice: z.coerce.number().positive().optional(),
});
Schema.parse({ reservePrice: "" }); // ZodError: too_small (0 is not positive)
// The user cleared the field; the schema sees 0, not undefined.
The fix is to decide emptiness before coercion, never after. A preprocess step maps "" to undefined, or a setValueAs in React Hook Form does the same, or a custom schema checks the raw string before passing it to Number() .
// Emptiness decided first
const OptionalNumber = z.preprocess(
(val) => (val === "" ? undefined : val),
z.coerce.number().optional()
);
The same problem applies to z.coerce.date(). new Date("") produces an Invalid Date, which fails z.date() validation, but the failure message points at the date schema rather than at the blank input. The user sees “Invalid date” when the real issue is that the field is optional and empty.
c. The boolean gap and z.stringbool()
z.coerce.boolean() is the most dangerous of the three because Boolean("false") is true. Every non-empty string is truthy in JavaScript, so a form field with the value "false" coerces to the boolean true .
z.coerce.boolean().parse("false"); // true
z.coerce.boolean().parse("0"); // true
z.coerce.boolean().parse(""); // false
Zod 4 added z.stringbool() to handle the string-to-boolean case correctly. It parses the string against a set of truthy and falsy values, with the default set including "true"/"false", "yes"/"no", "on"/"off", and "1"/"0" . The input must be a string, and the output is a boolean that matches the string’s meaning rather than its truthiness.
const Flag = z.stringbool();
Flag.parse("false"); // false
Flag.parse("no"); // false
Flag.parse("true"); // true
For form data where checkboxes send "on" or "off", z.stringbool() is the correct schema. z.coerce.boolean() should be reserved for cases where the input is genuinely a boolean already, or where any truthy value is acceptable.
Complete Example Session
// ============================================
// PART 1: NUMBER COERCION FROM FORM DATA
// ============================================
import { z } from "zod";
const QuantitySchema = z.coerce.number().int().positive();
QuantitySchema.parse("5"); // 5
QuantitySchema.parse("0"); // ZodError: too_small
// ============================================
// PART 2: THE BLANK-VERSUS-ZERO TRAP
// ============================================
const OptionalPrice = z.coerce.number().optional();
OptionalPrice.parse(""); // 0, not undefined (wrong for optional)
// Fix: preprocess decides emptiness first
const SafeOptionalPrice = z.preprocess(
(val) => (val === "" ? undefined : val),
z.coerce.number().optional()
);
SafeOptionalPrice.parse(""); // undefined
// ============================================
// PART 3: DATE COERCION
// ============================================
const DateSchema = z.coerce.date();
DateSchema.parse("2026-05-05"); // Date
DateSchema.parse("2026-05-05T10:00:00Z"); // Date
DateSchema.parse("not-a-date"); // ZodError: invalid_date
// ============================================
// PART 4: BOOLEAN COERCION
// ============================================
z.coerce.boolean().parse("false"); // true (wrong)
z.stringbool().parse("false"); // false (correct)
// ============================================
// PART 5: INPUT TYPE NARROWING
// ============================================
const TypedNumber = z.coerce.number<number>();
type In = z.input<typeof TypedNumber>; // number
type Out = z.output<typeof TypedNumber>; // number
The parts covered number coercion, the blank-versus-zero trap with the preprocess fix, date coercion, the boolean gap with z.stringbool(), and input type narrowing.
Quick Reference
Coercion Constructors
| Schema | Constructor | Input → Output |
|---|---|---|
z.coerce.number() | Number() | unknown → number |
z.coerce.date() | new Date() | unknown → Date |
z.coerce.string() | String() | unknown → string |
z.stringbool() | String set match | string → boolean |
The Traps
| Input | z.coerce.number() | z.coerce.date() | z.coerce.boolean() |
|---|---|---|---|
"" | 0 | Invalid Date | false |
"false" | NaN | Invalid Date | true |
"0" | 0 | Invalid Date | true |
v4 Input Type
| Schema | z.input |
|---|---|
z.coerce.number() | unknown |
z.coerce.number<number>() | number |
Best Practices
✅ Do This:
// Decide emptiness before coercion
z.preprocess((v) => (v === "" ? undefined : v), z.coerce.number()) // ✅
// Use stringbool for form booleans
z.stringbool().parse("false") // false // ✅
// Narrow input type when known
z.coerce.number<number>() // ✅
❌ Don’t Do This:
// Optional field with coerce.number
z.coerce.number().optional() // "" becomes 0, not undefined // ❌
// coerce.boolean on form strings
z.coerce.boolean().parse("false") // true // ❌
// Assume invalid date message explains blank
z.coerce.date().optional() // "" becomes Invalid Date // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Optional number cannot be blank | "" coerces to 0 | Preprocess to undefined |
"false" becomes true | Boolean("false") is truthy | Use z.stringbool() |
| Date error points at wrong cause | "" becomes Invalid Date | Check blank before coercion |
| Input type too wide | Default z.input is unknown | Use generic narrowing |
Real-World Examples
1. Form integer
z.coerce.number().int().positive();
2. Optional with blank handling
z.preprocess((v) => (v === "" ? undefined : v), z.coerce.number().optional());
3. Date from ISO string
z.coerce.date().parse("2026-05-05");
4. Boolean from form
z.stringbool().parse("on"); // true
5. Narrowed input
z.coerce.number<number>();
6. Epoch millis to Date
z.coerce.date().parse(1746439200000);
7. String coercion
z.coerce.string().parse(123); // "123"
8. React Hook Form alternative
setValueAs: (v) => (v === "" ? undefined : Number(v)); //
9. Required number with NaN
z.number().parse(NaN); // ZodError: expected number
10. Query param number
z.coerce.number().parse(req.query.page);
Visual
Coercion Pipeline
┌─────────────────────────────────────────────────────────┐
│ z.coerce.number() │
│ │
│ input ──► Number(input) ──► z.number() checks ──► out │
│ │ │ │
│ ▼ ▼ │
│ "42" 42 │
│ "" 0 ◄── blank becomes zero │
│ "abc" NaN ◄── fails number check │
└─────────────────────────────────────────────────────────┘
The Blank Trap
┌─────────────────────────────────────────────────────────┐
│ OPTIONAL NUMBER FIELD │
│ │
│ User clears input → "" │
│ │ │
│ ▼ │
│ z.coerce.number().optional() │
│ │ │
│ ▼ │
│ Number("") = 0 │
│ │ │
│ ▼ │
│ 0 is a number, optional() passes │
│ → undefined is never reached │
│ → the field stores 0, not blank │
└─────────────────────────────────────────────────────────┘
Boolean Coercion
┌─────────────────────────────────────────────────────────┐
│ z.coerce.boolean() z.stringbool() │
│ │
│ "false" → true "false" → false │
│ "0" → true "0" → false │
│ "" → false "" → error (not stringbool) │
│ │
│ Boolean() uses truthiness stringbool maps meanings │
└─────────────────────────────────────────────────────────┘
Input Type Narrowing
┌─────────────────────────────────────────────────────────┐
│ z.coerce.number() │
│ z.input → unknown │
│ │
│ z.coerce.number<number>() │
│ z.input → number │
│ │
│ Use narrowing when the source type is known │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Number coercion | z.coerce.number() → Number(input) |
| Date coercion | z.coerce.date() → new Date(input) |
| String coercion | z.coerce.string() → String(input) |
| Blank to number | "" → 0 (trap) |
| Boolean string | z.stringbool() for "false" → false |
| Input type default | unknown |
| Input narrowing | z.coerce.number<number>() |
Key takeaways:
- Coercion is a constructor call before validation.
z.coerce.number()runsNumber(),z.coerce.date()runsnew Date(), and the schema validates the result. The output type is the primitive; the input type defaults tounknown. - Blank is not zero.
Number("")is0, which means an optional field withz.coerce.number().optional()cannot be cleared—the empty string becomes a valid zero before the optional check runs. Decide emptiness before coercion, not after . z.coerce.boolean()is a trap for form data.Boolean("false")istrue, so every non-empty string becomestrue. Usez.stringbool()in Zod 4 for string-to-boolean parsing that respects the string’s meaning .- Date coercion fails on blank with a misleading error.
new Date("")produces anInvalid Date, and the validation error points at the date schema rather than the blank input. Handle optional dates with a preprocess step that maps""toundefined. - Narrow the input type when the source is known.
z.coerce.number<number>()types the input asnumber, eliminating theunknownat the call site when the value is already a number .
Remember: Coercion is convenience, not safety. It converts first and validates second, which means the conversion can produce a value that passes validation but does not represent the user’s intent. Blank becomes zero, "false" becomes true, and an invalid date becomes a schema error that blames the wrong thing. Use z.coerce for required fields where the input is genuinely a string, use z.preprocess or setValueAs for optional fields where blank must remain blank, and use z.stringbool() for booleans. The coercion is the bridge; make sure the bridge does not silently change the destination.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!