hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

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’s Boolean(), which means Boolean("false") is true (non-empty string). If you need "false" to produce false, use z.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()?

← Refinements Pick, omit, and extend →

© 2026 hectoday. All rights reserved.