Step-by-step
-
1
Install Zod
Zod has zero runtime dependencies and works with plain JavaScript or TypeScript. Install it once and use it across the entire project.
bashnpm install zod -
2
Define your first schema
A Zod schema describes the exact shape and constraints you expect. Declare it at the top of the file (or in a dedicated
schemas/folder) so the same schema can be shared by the route handler and any unit tests.typescript// src/schemas/user.schema.ts (or .js) import { z } from "zod" export const createUserSchema = z.object({ name: z.string().min(2).max(100).trim(), email: z.string().email().toLowerCase(), age: z.number().int().min(18).max(120), role: z.enum(["admin", "user"]).default("user"), }) // TypeScript: infer the type — no separate interface needed export type CreateUserInput = z.infer<typeof createUserSchema> // { name: string; email: string; age: number; role: "admin" | "user" } -
3
Use safeParse to validate a request
schema.parse()throws on failure.schema.safeParse()returns a result object instead — use it in route handlers so you control the response. On success,result.datais the parsed, sanitised value (trimmed strings, defaults applied). On failure,result.errorcontains the full list of issues.typescript// src/controllers/usersController.ts import { createUserSchema } from "../schemas/user.schema" export const create = (req: Request, res: Response) => { const result = createUserSchema.safeParse(req.body) if (!result.success) { return res.status(422).json({ error: "Validation failed", issues: result.error.issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, })), }) } // result.data is fully typed and safe to use const { name, email, age, role } = result.data // ... save to DB res.status(201).json({ name, email, age, role }) } -
4
Return structured validation errors
Clients need machine-readable error details, not just a generic 422. The format below gives each issue its field path and a human-readable message — easy to display inline next to form fields.
json// Example response body when validation fails // POST /api/users {"name":"A","email":"not-an-email","age":15} { "error": "Validation failed", "issues": [ { "field": "name", "message": "String must contain at least 2 character(s)" }, { "field": "email", "message": "Invalid email" }, { "field": "age", "message": "Number must be greater than or equal to 18" } ] } -
5
Extract a reusable validate middleware
Putting
safeParsecalls inline in every controller is repetitive. Write a higher-order middleware factory that takes a schema and returns a middleware — routes stay clean and validation is declarative.typescript// src/middleware/validate.ts import { ZodSchema } from "zod" import { Request, Response, NextFunction } from "express" export function validate(schema: ZodSchema) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.body) if (!result.success) { return res.status(422).json({ error: "Validation failed", issues: result.error.issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, })), }) } req.body = result.data // replace raw body with parsed + sanitised data next() } } // Usage in a route file: import { validate } from "../middleware/validate" import { createUserSchema } from "../schemas/user.schema" router.post("/", validate(createUserSchema), usersController.create) -
6
Sanitise strings inside the schema
Zod transforms run during parsing, so sanitisation is built into the schema — not scattered across controllers. Trim whitespace, normalise case, and enforce length limits in one place. The value in
req.bodyaftersafeParseis already clean.typescriptexport const createPostSchema = z.object({ title: z .string() .min(5, "Title must be at least 5 characters") .max(200, "Title cannot exceed 200 characters") .trim(), // strip leading/trailing whitespace slug: z .string() .toLowerCase() // normalise before storing .regex(/^[a-z0-9-]+$/, "Slug can only contain lowercase letters, numbers, and hyphens"), content: z .string() .min(20) .max(50_000), // prevent megabyte-sized payloads published: z.boolean().default(false), tags: z .array(z.string().trim().max(50)) .max(10, "No more than 10 tags allowed") .default([]), }) -
7
Validate route params and query strings
Zod is not limited to request bodies. Validate route parameters (which are always strings in Express) and query strings the same way. Cast types explicitly —
req.params.idcomes in as a string and must be coerced to a number.typescript// Validate route params const idParamSchema = z.object({ id: z.coerce.number().int().positive(), // coerces "42" → 42 }) // Validate query strings const listQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(20), search: z.string().trim().max(100).optional(), }) // validate middleware extended for params / query: export function validateParams(schema: ZodSchema) { return (req: Request, res: Response, next: NextFunction) => { const result = schema.safeParse(req.params) if (!result.success) { return res.status(400).json({ error: "Invalid URL parameter", issues: result.error.issues }) } req.params = result.data next() } }
Tips & gotchas
- Use `z.coerce.number()` instead of `z.number()` for any value that might come in as a string (route params, query strings). It converts `"42"` to `42` before validating.
- Put your schemas in a `src/schemas/` folder and import them into both the route handlers and your tests. A schema doubles as documentation of the expected API contract.
- Use `z.discriminatedUnion()` when a request body can have different shapes depending on a `type` field — it's faster and gives better error messages than a plain `z.union()`.
- `result.error.flatten()` is a convenient alternative to `.issues` that groups errors by field: `{ fieldErrors: { email: ["Invalid email"] }, formErrors: [] }` — useful for form validation responses.
Wrapping up
Zod turns input validation from an afterthought into a first-class part of the API contract. Define the schema once, get runtime safety and TypeScript types for free, and handle errors in a single middleware instead of scattering checks across every handler. Combined with the validate middleware pattern, every route in the project gets consistent, structured error responses with minimal per-route code.