Preprocessing and coercion
The type mismatch problem
Here is a situation you will hit the moment you work with query parameters. Your schema expects numbers and booleans, but HTTP delivers everything as strings:
const schema = z.object({
page: z.number().int().min(1),
active: z.boolean(),
});
schema.parse({ page: "2", active: "true" });
// fails: page: "Expected number, received string"
// fails: active: "Expected boolean, received string" The data is conceptually correct. "2" is the number 2. "true" is the boolean true. But the types are wrong, and Zod is strict about types. You need a way to convert before validating.
z.coerce: automatic type conversion
z.coerce wraps a schema with automatic type conversion. It calls the corresponding JavaScript constructor (Number(), Boolean(), String(), Date()) before validating:
import { z } from "zod/v4";
z.coerce.number().parse("42"); // returns 42 (calls Number("42"))
z.coerce.number().parse("3.14"); // returns 3.14
z.coerce.number().parse("abc"); // fails: NaN is rejected
z.coerce.number().parse(true); // returns 1 (Number(true) === 1)
z.coerce.boolean().parse("true"); // returns true
z.coerce.boolean().parse(""); // returns false (Boolean("") === false)
z.coerce.boolean().parse(0); // returns false
z.coerce.string().parse(42); // returns "42" (String(42))
z.coerce.string().parse(true); // returns "true"
z.coerce.date().parse("2024-01-15"); // returns Date object
z.coerce.date().parse(1705276800000); // returns Date from timestamp z.coerce.number() calls Number(value) first, then runs the result through z.number(). If Number() produces NaN (like with "abc"), the number validation catches it.
Coercion with constraints
Coercion runs first, then constraints apply to the converted value:
const PageSchema = z.coerce.number().int().min(1).max(100);
PageSchema.parse("5"); // coerces to 5, validates int/min/max — passes
PageSchema.parse("200"); // coerces to 200, fails max(100)
PageSchema.parse("abc"); // coerces to NaN, fails number check The chain is: convert the string to a number, then check if it is an integer, then check if it is at least 1, then check if it is at most 100. Coercion and validation in one declaration.
Building a query parameter schema
This is where coercion really pays off. Every query parameter arrives as a string, and you typically want numbers, booleans, and sensible defaults:
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(["title", "date", "rating"]).default("title"),
active: z.coerce.boolean().optional(),
});
QuerySchema.parse({ page: "2", limit: "50", sort: "date" });
// returns { page: 2, limit: 50, sort: "date", active: undefined }
QuerySchema.parse({});
// returns { page: 1, limit: 20, sort: "title", active: undefined } Every string query parameter gets converted to the right type. Missing parameters get sensible defaults. Invalid values fail with clear errors. This one schema replaces a bunch of scattered parseInt calls and manual default logic in your route handler.
z.preprocess(): custom conversion
For more complex conversions that z.coerce does not handle, there is z.preprocess(). It runs a custom function before any validation:
const CommaSeparated = z.preprocess(
(val) => (typeof val === "string" ? val.split(",") : val),
z.array(z.string().min(1)),
);
CommaSeparated.parse("a,b,c"); // returns ["a", "b", "c"]
CommaSeparated.parse(["a", "b"]); // returns ["a", "b"] (already an array) The first argument is the preprocessing function. The second is the schema to validate the preprocessed data against. The function runs before any validation, so it can transform the raw input into a shape that the schema expects.
[!WARNING]
z.coerce.boolean()uses JavaScript’sBoolean(), which meansBoolean("false")istrue(non-empty string). If you need"false"to producefalse, usez.preprocess():z.preprocess((val) => val === "true" || val === "1" || val === true, z.boolean());
This is a gotcha worth remembering. Boolean("false") returns true because "false" is a non-empty string. JavaScript truthiness is not the same as parsing the word “false.” If your API sends the literal string "false" as a query parameter, z.coerce.boolean() will happily turn it into true. Use z.preprocess() when you need precise control over conversion logic.
With coercion and preprocessing covered, we now have all the tools for transforming data during validation. Next, we move to schema composition, where we start building schemas from other schemas instead of writing everything from scratch.
Exercises
Exercise 1: Create a query params schema with page (number), limit (number), and sort (enum). Use z.coerce and .default(). Parse string values.
Exercise 2: Use z.preprocess() to convert a comma-separated string into an array of trimmed strings.
Exercise 3: Test z.coerce.boolean() with "true", "false", "", 0, 1. Note which values produce true vs false. Are the results what you expect?
Why does z.coerce.number() exist when you could just use parseInt()?