Input Validation, Error Handling, and Middleware Pipelines19 min read
Module P-4·19 min read
Zod for runtime schema validation, custom error classes, Express global error handler, the async wrapper that eliminates try/catch in every route handler.
What this module covers: Every route handler needs two things before it touches business logic: confirmation that the input is valid, and a plan for when something goes wrong. This module covers Zod for runtime schema validation, the async wrapper pattern that eliminates try/catch boilerplate from every handler, the custom error class hierarchy from P-1 extended with validation errors, and building the global error handler that translates every possible failure into a clean JSON response. By the end your handlers will be ten lines each and your error responses will be consistent across every route.
if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'name is required and must be a string' }); } if (!email || !email.includes('@')) { return res.status(400).json({ error: 'email must be a valid email address' }); } if (!password || password.length < 8) { return res.status(400).json({ error: 'password must be at least 8 characters' }); }
// ... finally the actual logic }
This is brittle (the email check is wrong), not reusable, and not consistent. The same logic gets copy-pasted and diverges. A schema library solves all of this.
Zod lets you define a schema once and get three things for free: validation, error messages, and TypeScript types.
TypeScript is the visa you're issued before you leave home; Zod is the customs officer physically checking your passport at the border — the visa can say whatever it wants, but nothing crosses without the officer's runtime check. TypeScript's guarantees evaporate the moment tsc finishes compiling; Zod's parse/safeParse calls run against every real request, on every deploy, for as long as the service is up.
npm install zod
import { z } from 'zod';
// Define the schema const createUserSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), email: z.string().email('Must be a valid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), role: z.enum(['user', 'admin']).default('user'), });
// Infer the TypeScript type from the schema — no duplication type CreateUserInput = z.infer<typeof createUserSchema>; // { name: string; email: string; password: string; role: 'user' | 'admin' }
// Parse and validate (throws ZodError if invalid) const input = createUserSchema.parse(req.body);
// Or safe parse — never throws, returns { success, data } | { success: false, error } const result = createUserSchema.safeParse(req.body); if (!result.success) { console.log(result.error.issues); // array of { path, message } }
.transform() lets a schema change the shape of the data, not just check it — and z.infer follows the output type through the transform automatically:
const createOrderSchema = z.object({ items: z.array(z.object({ productId: z.number().int().positive(), quantity: z.number().int().min(1).max(999), })), // couponCode arrives as a string or is absent — transform to null so downstream // code only ever has to handle one "no coupon" value, not two. couponCode: z.string().optional().transform(v => v || null), });
type CreateOrderInput = z.infer<typeof createOrderSchema>; // couponCode: string | null — the schema's *output* type, not the input's `string | undefined`
This is easy to miss when refactoring: z.infer always reflects what the schema produces after transforms run, not what it accepts. If a teammate adds a .transform() to an existing schema, every consumer's inferred type changes with it — TypeScript will flag the mismatches, but only if you re-run tsc after the change.
Headers are just as validatable as body/query/params — req.headers is a plain object, so the same validate() middleware works once you widen its target type:
Two gotchas specific to headers: Node lower-cases incoming header names, so schema keys must be lowercase ('x-api-key', never 'X-API-Key'); and reassigning req.headers = result.data — the same pattern validate() uses for body/query/params — replaces the entire headers object, so make sure your schema doesn't accidentally drop headers other middleware still needs.
export const updateUserSchema = createUserSchema .partial() .omit({ role: true }) .refine( data => Object.keys(data).length > 0, { message: 'At least one field must be provided' } );
// Inferred types — import these in services/controllers export type CreateUserInput = z.infer<typeof createUserSchema>; export type UpdateUserInput = z.infer<typeof updateUserSchema>;
// src/validators/common.schema.ts import { z } from 'zod';
Every async route handler has the same try/catch wrapper. Remove it entirely:
// Without wrapper — repeated 50+ times across controllers export async function createUser(req: Request, res: Response, next: NextFunction) { try { const user = await usersService.create(req.body); res.status(201).json(user); } catch (err) { next(err); // same in every handler } }
// With wrapper — the wrapper handles the catch export const createUser = asyncHandler(async (req, res) => { const user = await usersService.create(req.body); res.status(201).json(user); });
That's it. Any error thrown inside the async function — including AppError, ZodError, or PrismaClientKnownRequestError — is forwarded to next(err) automatically. The global error handler takes over from there.
With the wrapper, controllers become trivial to read and write:
// src/controllers/users.controller.ts import { asyncHandler } from '../utils/asyncHandler.js'; import * as usersService from '../services/users.service.js';
No try/catch, no explicit next. Errors surface automatically.
Version note: This pattern targets Express 4, which is what this course (and the sample code) uses. Express 5 auto-forwards rejected promises from async handlers to next() — the router itself awaits the handler and calls next(err) if it rejects, so asyncHandler is no longer necessary for this specific purpose on Express 5. It's still harmless to keep using it there (and it makes the "errors are handled" behavior explicit and version-independent), but know that the wrapper's core job is Express-4-specific.
// 429 — rate limit hit (for custom rate limit logic) export class TooManyRequestsError extends AppError { constructor(message = 'Too many requests') { super(message, 429); } }
Services throw these; the error handler maps them to HTTP responses. No HTTP status codes anywhere in the business logic layer.
Production story: An indexer's event-ingest API had a hierarchy much like this one — NotFoundError, ConflictError, ForbiddenError — but nothing for "this event references a block height we haven't caught up to yet." During a chain reorg, events started arriving out of order, referencing heights the indexer hadn't ingested yet. With no dedicated class to throw, that code path fell through to a generic Error, which the error handler's catch-all mapped to a plain 500. The client's retry logic treated every 500 as transient and retried with exponential backoff — a reasonable policy for an actual server fault, but wrong for an ordering problem that resolves on its own once the indexer catches up. Retries stacked on top of the reorg's natural burst of out-of-order events and roughly doubled ingest traffic for the duration of the storm. The fix was a dedicated 409 StaleBlockError extending AppError, so clients could distinguish "try again once you've caught up" from "something is actually broken" and back off accordingly.
Notice the handler actually reads err.isOperational before deciding what to send the client. Every AppError defaults to isOperational: true, so ordinary expected failures (NotFoundError, ConflictError, and friends) still get their message exposed as before. The flag only matters when something constructs an AppError with isOperational: false — a signal that this is a bug, not a foreseen failure — in which case the client gets a generic message and the real one is logged server-side instead of shipped over the wire.
Register it after all routes in index.ts:
// src/index.ts import express from 'express'; import { errorHandler } from './middleware/errorHandler.js'; import usersRouter from './routes/users.routes.js'; import postsRouter from './routes/posts.routes.js';
// Must be last — error handler has 4 parameters app.use(errorHandler);
The four-parameter signature is how Express recognises an error handler. If you accidentally use three parameters, it will be treated as regular middleware and errors will pass through it silently.
// src/controllers/orders.controller.ts import { asyncHandler } from '../utils/asyncHandler.js'; import * as ordersService from '../services/orders.service.js';
export async function getOrderById(orderId: number, requestingUserId: number) { const order = await ordersRepo.findById(orderId); if (!order) throw new NotFoundError('Order'); if (order.userId !== requestingUserId) throw new ForbiddenError(); return order; }
The error flows: service throws NotFoundError → asyncHandler catches it → passes to next(err) → error handler maps it to 404 { error: 'Order not found' }. The controller never needs to know.
asyncHandler and the global error handler catch everything thrown inside a request's lifecycle. Neither catches a rejected promise that isn't tied to a request at all — a stray .then() without a .catch(), a fire-and-forget background job, a timer callback. For those, add a process-level listener:
// src/index.ts process.on('unhandledRejection', (reason) => { console.error('Unhandled promise rejection:', reason); // Treat this like a non-operational AppError: the process is now in a state // nobody planned for. Log it, then exit — let your process manager (PM2, // systemd, Kubernetes) restart with a clean slate rather than keep serving // requests from a process whose internal state you no longer trust. process.exit(1); });
This pairs naturally with the isOperational flag on AppError. Operational errors — NotFoundError, ConflictError, a failed validation — are expected, handled per-request by the error handler, and never reach here. Anything that surfaces via unhandledRejection is, by definition, a bug nobody accounted for — the same category isOperational: false describes. Crashing and letting the process manager restart is safer than limping along with a process in an unknown state.
Zod validates input at runtime and infers TypeScript types — define the schema once, get both validation and types.
validate(schema, target) middleware runs before controllers and returns 400 with structured error messages if input is invalid. Replace req[target] with the parsed data so downstream code gets transformed, coerced values.
asyncHandler wraps async route handlers and forwards thrown errors to next(err). Eliminates try/catch boilerplate from every controller.
Typed error classes (NotFoundError, ConflictError, etc.) let services express failure without knowing about HTTP. The error handler translates them to status codes.
The global error handler handles Zod errors, AppErrors, Prisma errors, JWT errors, and unknown errors in one place. Register it last, with four parameters.
Consistent error shape — { error: string, issues?: [] } for every failure — lets the frontend handle errors generically.
Next: testing — unit testing services with Jest, integration testing routes with Supertest, and mocking the layers below the unit under test.
Knowledge Check
How does Express recognize a middleware function as a global error handler?
What is the primary purpose of an asyncHandler utility in an Express application?
When using Zod in validation middleware, why is it beneficial to replace the request object data (e.g., req.body = parsedData) after successful validation?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.