Request Validation#
Type-safe request validation for req.body, req.query, and req.params using Zod schemas.
Validation Middleware#
import type { NextFunction, Request, Response } from "express";
import { StatusCodes } from "http-status-codes";
import { z, ZodError } from "zod";
import logger from "../utils/logger.js";
export function validateSchema<
TBody extends z.ZodSchema = z.ZodNever,
TQuery extends z.ZodSchema = z.ZodNever,
TParams extends z.ZodSchema = z.ZodNever,
>(schemas: { params?: TParams; query?: TQuery; body?: TBody }) {
return (req: Request, res: Response, next: NextFunction) => {
const errs: {
params?: ZodError;
query?: ZodError;
body?: ZodError;
} = {};
req.validated = {};
// Validate params
if (schemas.params) {
const res = schemas.params.safeParse(req.params);
if (res.success) {
req.validated.params = res.data;
} else {
errs.params = res.error;
}
}
// Validate query
if (schemas.query) {
const res = schemas.query.safeParse(req.query);
if (res.success) {
req.validated.query = res.data;
} else {
errs.query = res.error;
}
}
// Validate body
if (schemas.body) {
const res = schemas.body.safeParse(req.body);
if (res.success) {
req.validated.body = res.data;
} else {
errs.body = res.error;
}
}
// Return errors if any
if (Object.keys(errs).length > 0) {
const issues = {
params: errs.params && formatZodError(errs.params),
query: errs.query && formatZodError(errs.query),
body: errs.body && formatZodError(errs.body),
};
logger.debug("Request validation failed", { issues });
return res.status(StatusCodes.BAD_REQUEST).json({
error: "Request validation failed",
issues,
});
}
next();
};
}
function formatZodError(error: ZodError): any[] {
return error.issues.map((issue) => ({
path: issue.path.join(".") || "root",
message: issue.message,
code: issue.code,
expected: "expected" in issue ? issue.expected : undefined,
received: "received" in issue ? issue.received : undefined,
}));
}
Type Helper#
type InferValidated<T extends Record<string, z.ZodSchema>> = {
[K in keyof T]: z.infer<T[K]>;
};
export function getValidatedData<T extends Record<string, z.ZodSchema>>(
req: Request,
_schema: T // For type inference only
) {
return req.validated as InferValidated<T>;
}
TypeScript Declarations#
// types/express.d.ts
import "express";
declare global {
namespace Express {
interface Request {
validated?: {
params?: any;
query?: any;
body?: any;
};
}
}
}
Define Schemas#
// schemas/users.ts
import { z } from "zod";
export const userSchemas = {
// List users - pagination query params
list: {
query: z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
}),
},
// Get user by ID - validate params
getById: {
params: z.object({
id: z.string().uuid(),
}),
},
// Create user - validate body
create: {
body: z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18).optional(),
}),
},
// Update user - validate params + body
update: {
params: z.object({
id: z.string().uuid(),
}),
body: z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
age: z.number().int().min(18).optional(),
}),
},
// Search users - validate query
search: {
query: z.object({
q: z.string().min(1),
limit: z.coerce.number().default(10),
role: z.enum(["ADMIN", "USER", "GUEST"]).optional(),
}),
},
};
Usage in Routes#
import { Router } from "express";
import type { Request, Response } from "express";
import { validateSchema, getValidatedData } from "../middleware/validation.js";
import { userSchemas } from "../schemas/users.js";
import { requireAdmin, requireAuth } from "../middleware/auth.js";
const router = Router();
// List users with pagination
router.get(
"/",
requireAdmin,
validateSchema(userSchemas.list),
async (req: Request, res: Response) => {
const { query } = getValidatedData(req, userSchemas.list);
const users = await listUsers(query.limit, query.offset);
res.json({ users });
}
);
// Get user by ID
router.get(
"/:id",
requireAuth(),
validateSchema(userSchemas.getById),
async (req: Request, res: Response) => {
const { params } = getValidatedData(req, userSchemas.getById);
const user = await getUserById(params.id);
res.json({ user });
}
);
// Create user
router.post(
"/",
validateSchema(userSchemas.create),
async (req: Request, res: Response) => {
const { body } = getValidatedData(req, userSchemas.create);
const user = await createUser(body);
res.status(201).json({ user });
}
);
// Update user
router.put(
"/:id",
requireAuth(),
validateSchema(userSchemas.update),
async (req: Request, res: Response) => {
const { params, body } = getValidatedData(req, userSchemas.update);
const user = await updateUser(params.id, body);
res.json({ user });
}
);
// Search users
router.get(
"/search",
requireAuth(),
validateSchema(userSchemas.search),
async (req: Request, res: Response) => {
const { query } = getValidatedData(req, userSchemas.search);
const users = await searchUsers(query.q, query.limit, query.role);
res.json({ users });
}
);
export { router as usersRouter };
Validation Error Response#
{
"error": "Request validation failed",
"issues": {
"body": [
{
"path": "email",
"message": "Invalid email",
"code": "invalid_string",
"expected": "string",
"received": "test"
},
{
"path": "password",
"message": "String must contain at least 8 character(s)",
"code": "too_small",
"expected": 8,
"received": 5
}
]
}
}
Common Validation Patterns#
Pagination#
const paginationSchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
});
// URL: /api/users?limit=10&offset=20
UUID Params#
const idSchema = z.object({
id: z.string().uuid(),
});
// URL: /api/users/123e4567-e89b-12d3-a456-426614174000
Search Query#
const searchSchema = z.object({
q: z.string().min(1),
limit: z.coerce.number().default(10),
sortBy: z.enum(["createdAt", "name", "email"]).default("createdAt"),
order: z.enum(["asc", "desc"]).default("desc"),
});
// URL: /api/users/search?q=john&sortBy=name&order=asc
Optional Fields#
const updateSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
age: z.number().int().min(18).optional(),
});
// All fields are optional
Enum Validation#
const roleSchema = z.object({
role: z.enum(["ADMIN", "USER", "MODERATOR", "GUEST"]),
});
Nested Objects#
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}$/),
country: z.string().length(2), // ISO code
});
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
address: addressSchema,
});
Arrays#
const bulkCreateSchema = z.object({
users: z.array(
z.object({
name: z.string().min(2),
email: z.string().email(),
})
).min(1).max(100),
});
Coercion (Query Params)#
// Query params come as strings, use .coerce to convert
const querySchema = z.object({
limit: z.coerce.number(), // "10" → 10
active: z.coerce.boolean(), // "true" → true
tags: z.string().transform(s => s.split(",")), // "a,b,c" → ["a","b","c"]
});
Complete Validation Flow#
// 1. Client sends request
POST /api/users
{
"name": "Alice",
"email": "invalid-email",
"password": "short"
}
// 2. Validation middleware runs
validateSchema(userSchemas.create)
// 3. Zod validates the body
const result = userSchemas.create.body.safeParse(req.body);
// 4. Validation fails
if (!result.success) {
return res.status(400).json({
error: "Request validation failed",
issues: { body: [...] }
});
}
// 5. If valid, attach to req.validated
req.validated.body = result.data;
// 6. Route handler gets typed data
const { body } = getValidatedData(req, userSchemas.create);
// body is fully typed: { name: string; email: string; password: string; age?: number }
Best Practices#
- ✅ Validate all inputs - params, query, and body
- ✅ Use coerce for query params - They come as strings
- ✅ Set defaults - Use
.default()for optional values - ✅ Use getValidatedData() - Get fully typed validated data
- ✅ Reuse schemas - Define once, use in multiple routes
- ✅ Validate before auth - Or after, depending on needs
- ✅ Return detailed errors - Help clients fix validation issues
Schema Organization#
schemas/
├── users.ts
├── posts.ts
├── comments.ts
├── common.ts # Shared schemas (pagination, etc.)
└── index.ts # Export all schemas
// schemas/common.ts
export const paginationSchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0),
});
export const idParamSchema = z.object({
id: z.string().uuid(),
});
// schemas/users.ts
import { paginationSchema, idParamSchema } from "./common.js";
export const userSchemas = {
list: { query: paginationSchema },
getById: { params: idParamSchema },
// ...
};
Next Steps#
- Complete Request Flow - See validation in full context
- Zod Guide - Deep dive into Zod schemas
- Drizzle Guide - Use validated data in database queries