| | |

Zod 15 🛡️ Coercion — z.coerce for parsing strings into numbers and dates

⏱️ Estimated read: 9 min | |

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

SchemaConstructorInput → Output
z.coerce.number()Number()unknown → number
z.coerce.date()new Date()unknown → Date
z.coerce.string()String()unknown → string
z.stringbool()String set matchstring → boolean

The Traps

Inputz.coerce.number()z.coerce.date()z.coerce.boolean()
""0Invalid Datefalse
"false"NaNInvalid Datetrue
"0"0Invalid Datetrue

v4 Input Type

Schemaz.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

PitfallWhy It HappensFix
Optional number cannot be blank"" coerces to 0Preprocess to undefined
"false" becomes trueBoolean("false") is truthyUse z.stringbool()
Date error points at wrong cause"" becomes Invalid DateCheck blank before coercion
Input type too wideDefault z.input is unknownUse 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

ItemValue
Number coercionz.coerce.number() → Number(input)
Date coercionz.coerce.date() → new Date(input)
String coercionz.coerce.string() → String(input)
Blank to number"" → 0 (trap)
Boolean stringz.stringbool() for "false" → false
Input type defaultunknown
Input narrowingz.coerce.number<number>()

Key takeaways:

  • Coercion is a constructor call before validation. z.coerce.number() runs Number(), z.coerce.date() runs new Date(), and the schema validates the result. The output type is the primitive; the input type defaults to unknown .
  • Blank is not zero. Number("") is 0, which means an optional field with z.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") is true, so every non-empty string becomes true. Use z.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 an Invalid Date, and the validation error points at the date schema rather than the blank input. Handle optional dates with a preprocess step that maps "" to undefined.
  • Narrow the input type when the source is known. z.coerce.number<number>() types the input as number, eliminating the unknown at 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.

Was this helpful?


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!