Request Validation
Every piece of data entering your system from outside is untrusted. Validation is your first line of defence — it ensures your business logic only ever sees data in the shape it expects.
What to Validate and Where
Validation targets four distinct sources of input per request. Each is untrusted and must be validated independently:
- req.body — the parsed JSON body. Can contain anything. Most critical to validate.
- req.params — route segments like
:id. Always strings — validate format and cast. - req.query — query string values. Always strings — must coerce numeric/boolean types.
- req.headers — rarely validated via schema, but specific headers (like Content-Type) matter.
{ "isAdmin": true, "role": "superuser", "balance": -99999 } in any request. Without validation, your application might process these fields silently. Always define the exact shape you accept and reject anything that doesn't match.
Zod — The Standard
npm install zod
Zod is a TypeScript-first schema declaration and validation library. In JavaScript it gives you runtime type safety — schemas parse raw input and return validated, typed data.
const { z } = require('zod');
// parse() — throws ZodError on failure
const data = schema.parse(input);
// safeParse() — returns result object, never throws (use this in Express)
const result = schema.safeParse(input);
if (!result.success) {
// result.error is a ZodError with detailed field-level messages
console.log(result.error.flatten().fieldErrors);
// { email: ['Invalid email'], age: ['Expected number, received string'] }
}
// result.data is the validated (and possibly transformed) value
Complete Zod Schema Reference
.min(n) .max(n) .length(n) .email() .url() .uuid() .cuid() .regex(/pattern/) .startsWith(s) .endsWith(s) .trim() .toLowerCase() .toUpperCase() .datetime() .ip().int() .positive() .negative() .nonnegative() .min(n) .max(n) .multipleOf(n) .finite() .safe()'42' → 42. Use for all req.query numeric fields.true/false. Does NOT coerce strings. Use z.coerce.boolean() or z.enum(['true','false']).transform(v => v === 'true') for query params.z.enum(['draft','published','archived']) — value must be one of these exactly. TypeScript gets the union type automatically.z.nativeEnum(UserRole)z.literal('active') — accepts only this exact value. Useful in discriminated unions.z.array(z.string()).min(1).max(10).nonempty() — array with item schema and length bounds..strict() — rejects unknown keys (recommended). .partial() — all fields optional (for PATCH). .pick({ name: true }) — subset. .omit({ password: true }) — exclude fields.z.union([z.string(), z.number()]) — value must match one of the schemas.z.discriminatedUnion('type', [dogSchema, catSchema])z.record(z.string(), z.number()) — arbitrary key-value map where all values match a schema.z.string().datetime() for ISO string dates from JSON.T | undefined.T | null..optional().nullable(). Type becomes T | null | undefined.z.string().transform(s => s.trim().toLowerCase())z.string().refine(s => s !== 'admin', 'Username "admin" is reserved')Real-World Schema Examples
const { z } = require('zod');
// User registration — strict shape, transform email
const RegisterSchema = z.object({
name: z.string().min(1).max(100).trim(),
email: z.string().email().toLowerCase(),
password: z.string().min(8).max(72) // bcrypt max is 72 chars
.regex(/[A-Z]/, 'Needs uppercase')
.regex(/[0-9]/, 'Needs a number'),
role: z.enum(['user', 'admin']).default('user'),
}).strict(); // reject any extra fields the client sends
// PATCH update — all fields optional, at least one required
const UpdateUserSchema = RegisterSchema
.omit({ password: true, role: true }) // can't change password/role here
.partial() // all remaining fields optional
.refine( // but at least one must be provided
(data) => Object.keys(data).length > 0,
{ message: 'At least one field is required' }
);
// Query params with coercion
const ListUsersSchema = 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(['name', 'email', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().max(100).optional(),
role: z.enum(['user', 'admin']).optional(),
});
// Cross-field validation — confirm password
const PasswordResetSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
);
The validate() Middleware Factory
Build one reusable middleware factory and use it across every route:
// src/middleware/validate.js
const { z } = require('zod');
const { ValidationError } = require('../errors/AppError');
/**
* validate(schema) — returns an Express middleware that:
* 1. Parses and validates req.body, req.params, req.query
* 2. Replaces them with validated/coerced/defaulted values
* 3. Calls next() on success, next(ValidationError) on failure
*
* Schema shape: z.object({ body: ..., params: ..., query: ... })
*/
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse({
body: req.body,
params: req.params,
query: req.query,
});
if (!result.success) {
const details = result.error.flatten().fieldErrors;
return next(new ValidationError(details));
}
// Replace with validated values — Zod applies defaults, transforms, coercions
req.body = result.data.body ?? req.body;
req.params = result.data.params ?? req.params;
req.query = result.data.query ?? req.query;
next();
};
module.exports = validate;
// src/schemas/users.js — all schemas for the users resource
const { z } = require('zod');
// Reusable sub-schemas
const userId = z.object({ params: z.object({ id: z.coerce.number().int().positive() }) });
module.exports = {
create: z.object({ body: CreateUserSchema }),
update: z.object({ body: UpdateUserSchema, params: userId.shape.params }),
getById: userId,
list: z.object({ query: ListUsersSchema }),
};
// src/routes/users.js — clean route file
const validate = require('../middleware/validate');
const schemas = require('../schemas/users');
const ctrl = require('../controllers/users');
router.get('/', validate(schemas.list), ctrl.getAll);
router.get('/:id', validate(schemas.getById), ctrl.getById);
router.post('/', validate(schemas.create), ctrl.create);
router.patch('/:id', validate(schemas.update), ctrl.update);
Custom Error Messages
// Zod accepts a custom message as the second argument to most validators
z.string().min(8, 'Password must be at least 8 characters')
z.string().email('Please enter a valid email address')
z.number().positive('Price must be a positive number')
z.enum(['admin', 'user'], { message: 'Role must be admin or user' })
// Custom message via options object
z.string({ required_error: 'Email is required', invalid_type_error: 'Email must be a string' })
.email('Invalid email format')
Stripping Unknown Fields — Security by Default
// Default z.object() behaviour:
// strips unknown keys silently
// but doesn't warn you they were there
z.object({ name: z.string() })
// { name: 'Alice', isAdmin: true }
// → { name: 'Alice' } (silently)// .strict() throws if any key
// in the input isn't in the schema
z.object({ name: z.string() })
.strict()
// { name: 'Alice', isAdmin: true }
// → ZodError: unrecognized key isAdmin.strict() on request body schemas. Note: don't use it on query schemas since browsers may add extra params.
Validating the Response — Output Schemas
Senior engineers also validate outbound data. An output schema strips sensitive fields (like password) before the response is sent, even if code accidentally selects them:
// Define what's safe to send to clients
const UserResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
role: z.string(),
createdAt: z.string(),
// password: intentionally omitted
// internalFlags: intentionally omitted
});
const sanitiseUser = (user) => UserResponseSchema.parse(user);
// In controller
const user = await db.user.findUnique({ where: { id } });
res.json({ data: sanitiseUser(user) }); // guaranteed no password in response
🧠 Check Your Understanding
Go Deeper
Primary source: zod.dev — the docs are excellent, especially the error handling and advanced sections.
Error formatting: Zod Error Handling — learn to format errors exactly as your API spec requires.
Ask your teacher: "Show me z.discriminatedUnion for a payment schema that handles both card and bank transfer." or "How do I write a Zod schema for a recursive comment tree?"