homesponsors ⭐
 
   

Request validation with Zod in Express

Published July 21, 2026Last updated July 22, 20264 min read

Express does not validate request input for you. Without a check at the edge, handlers get raw req.body, req.query, and req.params - strings where you expected numbers, missing fields, and shapes that only blow up deep in business logic.

Zod is a TypeScript-first schema library. You declare the shape once, infer types with z.infer, and parse at the HTTP boundary so route handlers only see valid data. Invalid input becomes HTTP 400 before your code runs.

This post covers Zod 4 schemas (z.email(), z.uuid(), z.coerce), Express validation middleware, error formatting and pitfalls.

Prerequisites

  • Node.js version 26
  • Zod 4: npm i zod
  • Express: npm i express and npm i -D @types/express

Zod 3 method forms like z.string().email() still work but are deprecated in v4. Prefer the top-level APIs below.

Schemas

// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});

z.coerce.number() is useful for query strings - HTTP query values arrive as strings. Prefer safeParse over parse at the edge so you control the HTTP status and response body.

Format errors once

Map ZodError.issues into a stable JSON body, or use Zod 4 helpers z.flattenError() / z.treeifyError() when you want field-keyed or nested shapes.

// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}

Validation middleware

Validate body, query, and params before the route handler. Write parsed data back so handlers receive typed, coerced values.

// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}

Wire it per route:

app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});

Query and params are stored on res.locals because Express types treat req.query / req.params as string maps; replacing them with coerced objects fights the type system.

Pitfalls

  • Query strings are strings - use z.coerce (or z.string() + transform) for numbers and booleans.
  • parse vs safeParse - parse throws a raw ZodError; map it to HTTP 400 yourself or stick to safeParse.
  • Strip unknown keys - Zod object schemas strip unknown keys by default. Use .strict() if you want to reject them.
  • Do not trust types alone - CreateUserInput is compile-time only; always parse at the boundary.
  • Stricter UUIDs in Zod 4 - z.uuid() follows RFC 9562/4122 more strictly; use z.guid() for a looser 8-4-4-4-12 hex pattern.