Zod Validation Middleware for Express.js Routes
Symptom: Route handlers receive malformed, undefined, or incorrectly typed payload fields despite syntactically valid JSON requests. Express logs surface unhandled promise rejections or return 500 Internal Server Error instead of a structured 400 Bad Request. This guide extends Runtime Validation with Zod with a complete middleware pattern that covers request body, query parameters, and route params.
Exact error output you will see without a proper validation layer:
TypeError: Cannot read properties of undefined (reading 'email')
ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": ["userId"]
}
]
UnhandledPromiseRejection: ZodError: [
{ "code": "too_small", "minimum": 18, "path": ["age"] }
]
Root Cause
Express executes express.json() as a body parser, not a validator. It converts the raw byte stream to a JavaScript object but never checks whether those values conform to your contract. The result is that req.body is typed as any — a structural hole that Zod’s .parse() fills only if you call it explicitly.
Three specific failure modes combine to produce the symptoms above:
- Inline parse without error handling. Calling
schema.parse(req.body)directly inside a handler throws aZodErrorthat propagates as an unhandled rejection unless a global error handler is in place. - Transform without gate. Schemas that use
.transform()mutate the parsed value. Ifparseis called before you check the input type, the transform can produce unexpected output on malformed data, and the error surfaces deeper in business logic rather than at the boundary. - Missing global fallback. Async route handlers (declared with
async) require a wrappingtry/catchor a library likeexpress-async-errors. Without it, any thrownZodErrorbypasses the four-argument Express error handler entirely.
Step-by-Step Fix
Step 1 — Install Zod 3.23 and Enable TypeScript Strict Mode
npm install zod@3.23
In tsconfig.json:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist"
}
}
Why this works: strict: true enables noImplicitAny and strictNullChecks, which means TypeScript will surface any place you use req.body without narrowing it to the correct type. Zod’s inferred types (z.infer<typeof Schema>) only provide compile-time guarantees when the surrounding code is strict.
Step 2 — Build a Generic validateBody Middleware Factory
Inline schema parsing inside route handlers fragments error handling and leaks implementation details. A middleware factory centralises the logic once:
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
export function validateBody<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);
if (!result.success) {
res.status(400).json({
error: 'VALIDATION_FAILED',
details: result.error.format(),
});
return; // short-circuit — do not call next()
}
req.body = result.data; // overwrite with the parsed, typed value
next();
};
}
Why this works: safeParse returns a discriminated union { success: true, data: T } | { success: false, error: ZodError }. Checking result.success before calling next() guarantees the handler only runs when the body is fully valid. Reassigning req.body = result.data means any .transform() or .default() defined in the schema has already been applied by the time the handler runs.
Step 3 — Define Strict Route Schemas
// src/schemas/user.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(18),
role: z.enum(['user', 'admin']).default('user'),
}).strict(); // reject unknown keys
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export const UpdateUserSchema = z.object({
email: z.string().email().optional(),
role: z.enum(['user', 'admin']).optional(),
}).strict();
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
Why this works: .strict() adds a key-enumeration check that rejects any field not declared in the schema. This prevents silent field injection — an attacker cannot sneak in a __proto__ key or a role: 'superadmin' field that a downstream service might interpret. It also enforces your API contract boundary: if a consumer sends a field you haven’t defined, they get a 400 that tells them exactly which key was unexpected.
Step 4 — Add validateQuery and validateParams Factories
Validating query parameters and environment variables follows the same factory pattern. Query values always arrive as strings, so include .transform() for coercion:
// src/middleware/validate.ts (continued)
export function validateQuery<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.query);
if (!result.success) {
res.status(400).json({
error: 'INVALID_QUERY_PARAMS',
details: result.error.format(),
});
return;
}
// Cast required: Express types req.query as ParsedQs
req.query = result.data as typeof req.query;
next();
};
}
export function validateParams<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.params);
if (!result.success) {
res.status(400).json({
error: 'INVALID_PATH_PARAMS',
details: result.error.format(),
});
return;
}
req.params = result.data as typeof req.params;
next();
};
}
Define query and param schemas with string-to-primitive transforms:
// src/schemas/user.ts (continued)
import { z } from 'zod';
export const GetUserParamsSchema = z.object({
id: z.string().uuid(),
});
export const GetUserQuerySchema = z.object({
include_deleted: z
.enum(['true', 'false'])
.optional()
.transform((v) => v === 'true'), // string → boolean
page: z
.string()
.regex(/^\d+$/, 'page must be a positive integer')
.optional()
.transform(Number), // string → number
});
Why this works: Express populates req.query with string values only — even ?page=2 arrives as "2". Defining transforms inside the Zod schema keeps the coercion co-located with the contract definition rather than scattered across handlers. The handler receives the correctly-typed number or boolean without any manual casting.
Step 5 — Wire the Middleware and Add a Global ZodError Fallback
// src/app.ts
import express from 'express';
import 'express-async-errors'; // patches async handler rejection propagation
import { ZodError } from 'zod';
import { validateBody, validateQuery, validateParams } from './middleware/validate';
import {
CreateUserSchema,
GetUserParamsSchema,
GetUserQuerySchema,
} from './schemas/user';
const app = express();
app.use(express.json()); // body-parser first, always
// POST /api/users — body validation only
app.post(
'/api/users',
validateBody(CreateUserSchema),
(req, res) => {
// req.body is CreateUserInput here — typed and validated
res.status(201).json({ id: 'usr_123', ...req.body });
}
);
// GET /api/users/:id — params + query validation
app.get(
'/api/users/:id',
validateParams(GetUserParamsSchema),
validateQuery(GetUserQuerySchema),
async (req, res) => {
const { id } = req.params; // string (UUID-validated)
const { include_deleted, page } = req.query as {
include_deleted?: boolean;
page?: number;
};
res.json({ id, include_deleted, page });
}
);
// Global ZodError fallback — catches anything that escapes middleware
app.use((
err: unknown,
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'CONTRACT_VIOLATION',
details: err.format(),
});
}
next(err);
});
export default app;
Why this works: The four-argument signature (err, req, res, next) is how Express identifies a handler as an error middleware. express-async-errors monkey-patches Router.prototype.process_params so that async handler rejections flow into this error middleware rather than being swallowed silently. If a ZodError somehow escapes the middleware factories (for example, from a custom validator called inside an async handler), this fallback converts it to a 400 rather than a 500.
Before / After
Before — inline parse, no structured error:
app.post('/api/users', async (req, res) => {
const body = CreateUserSchema.parse(req.body); // throws on failure → 500
res.status(201).json({ id: 'usr_123', ...body });
});
HTTP/1.1 500 Internal Server Error
{"error": "Internal Server Error"}
After — middleware factory, structured 400:
app.post('/api/users', validateBody(CreateUserSchema), (req, res) => {
res.status(201).json({ id: 'usr_123', ...req.body });
});
HTTP/1.1 400 Bad Request
{
"error": "VALIDATION_FAILED",
"details": {
"email": { "_errors": ["Invalid email"] },
"age": { "_errors": ["Number must be greater than or equal to 18"] }
}
}
The error response structure aligns with RFC 7807 problem-detail conventions: a machine-readable error code and a details map that consumer clients can render field-by-field.
Verification
Run the test suite against a live or in-process Express instance:
# Install supertest for HTTP assertions
npm install --save-dev supertest @types/supertest
# Run tests
npx jest --testPathPattern=routes/user
// src/routes/user.test.ts
import request from 'supertest';
import app from '../app';
test('POST /api/users — rejects invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', age: 25 });
expect(res.status).toBe(400);
expect(res.body.error).toBe('VALIDATION_FAILED');
expect(res.body.details.email._errors).toContain('Invalid email');
});
test('POST /api/users — accepts valid payload', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'ada@example.com', age: 30 });
expect(res.status).toBe(201);
expect(res.body.role).toBe('user'); // default applied by Zod
});
test('GET /api/users/:id — rejects non-UUID id', async () => {
const res = await request(app).get('/api/users/not-a-uuid');
expect(res.status).toBe(400);
expect(res.body.error).toBe('INVALID_PATH_PARAMS');
});
Expected CI log excerpt:
PASS src/routes/user.test.ts
✓ POST /api/users — rejects invalid email (18 ms)
✓ POST /api/users — accepts valid payload (4 ms)
✓ GET /api/users/:id — rejects non-UUID id (3 ms)
Edge Cases
- Async handlers without
express-async-errors. If you choose not to install the package, wrap everyasynchandler body intry/catchand callnext(err)on the caught error. Omitting this causes allZodErrorthrows inside async handlers to be swallowed by Node, producing no HTTP response and an eventual socket timeout. - Multipart / file upload routes.
express.json()does not parsemultipart/form-data. Validation middleware that callssafeParse(req.body)on a multipart route will receiveundefinedand report a type error. Use a parser likemulterfirst, then validatereq.bodyandreq.fileswith separate schemas. - Schema versioning across deployments. If you deploy a schema change that removes a field while old clients still send it,
.strict()will reject their payloads. Use a blue-green or canary deployment strategy and temporarily replace.strict()with.passthrough()during the transition window, reverting once all clients have migrated.
Frequently Asked Questions
Why does req.body remain typed as any even after Zod validation passes?
Express’s built-in Request interface declares req.body as any. You must augment the interface or assert the type in the handler. The middleware factory pattern here reassigns req.body = result.data, which retains the inferred type in the handler when you declare a typed handler parameter using the exported z.infer<typeof Schema> type alias.
Should I use parse or safeParse inside Express middleware?
Always use safeParse in middleware. parse throws a ZodError, which unwinds the call stack and requires a global error handler to catch it. safeParse returns a discriminated union so you can respond with res.status(400) inline and return — no exceptions, no async leak.
Does .strict() on a Zod object schema affect performance?
The overhead is negligible — Zod 3.23 strict mode adds one key-count comparison after the normal parse pass. The correctness benefit (rejecting undeclared fields that could carry prototype-pollution payloads or shadow future contract fields) far outweighs the cost.
How do I validate route params separately from the request body?
Write a validateParams factory that calls schema.safeParse(req.params) and a validateQuery factory for req.query. Mount them in order: validateParams → validateQuery → validateBody → route handler. Each factory writes its parsed result back onto the respective req property.
What is the right HTTP status code for a Zod schema failure?
400 Bad Request is correct for all schema violations. Reserve 422 Unprocessable Entity only for semantically invalid data that passes structural validation (e.g., a date range where end is before start). Zod catches structural failures, so 400 is the right default.