Input Validation, Error Handling, and Middleware Pipelines22 min read
Module P-4·22 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.
Module P-4 — Input Validation, Error Handling, and Middleware Pipelines
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.
The Problem with Manual Validation
Without a validation layer, every handler contains the same repetitive guard code:
javascript
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: Runtime Type Safety
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.
bash
typescript
Zod Schema Patterns
typescript
Transforms and Validating Headers
.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:
typescript
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:
typescript
typescript
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.
The Validate Middleware
Wrap Zod parsing into a reusable Express middleware factory:
typescript
Usage — schemas run before the controller, controller gets clean validated data:
typescript
When validation fails, the middleware returns before the controller is called:
json
Organising Your Schemas
Keep schemas next to the routes that use them:
text
typescript
typescript
The Async Wrapper: Eliminating try/catch Boilerplate
Every async route handler has the same try/catch wrapper. Remove it entirely:
typescript
The wrapper:
typescript
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:
typescript
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.
The Complete Error Class Hierarchy
Extending the hierarchy from P-1 to cover every scenario:
typescript
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.
The Global Error Handler
One function handles every possible error type:
typescript
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:
typescript
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.
Putting It All Together: A Complete Route
Here is a full POST /orders route with validation, auth, and async error handling — no boilerplate:
typescript
typescript
typescript
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.
Consistent Error Response Shape
Define the shape once in your API documentation and stick to it:
json
Every error, every route, same shape. Frontend developers write one error handler, not one per endpoint.
Request ID Middleware
Add a request ID to every request for correlating logs with errors:
typescript
typescript
Include the request ID in error logs:
typescript
Now when a client reports an error, they provide the x-request-id response header and you can find exactly that request in your logs.
A Process-Level Safety Net
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:
typescript
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.
Summary
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.
exportasyncfunctioncreateUser(req, res, next){const{ name, email, password }= req.body;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}
npminstall zod
import{ z }from'zod';// Define the schemaconst 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 duplicationtypeCreateUserInput= 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 }}
import{ z }from'zod';// String refinementsconst emailSchema = z.string().email().toLowerCase()// transform to lowercase before storing.trim();// strip whitespace// Numbersconst priceSchema = z.number().positive('Price must be positive').multipleOf(0.01,'Price must have at most 2 decimal places');// Optionals and defaultsconst paginationSchema = z.object({ page: z.coerce.number().int().positive().default(1),// coerce: '2' → 2 limit: z.coerce.number().int().min(1).max(100).default(20),});// Arraysconst createOrderSchema = z.object({ items: z.array(z.object({ productId: z.number().int().positive(), quantity: z.number().int().min(1).max(999),})).min(1,'Order must contain at least one item'), couponCode: z.string().optional(),});// Union typesconst statusSchema = z.union([ z.literal('active'), z.literal('banned'), z.literal('pending'),]);// Equivalent shorthand:const statusSchema2 = z.enum(['active','banned','pending']);// Partial for update endpoints (all fields optional)const updateUserSchema = createUserSchema.partial().omit({ role:true});// Refine for cross-field validationconst dateRangeSchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(),}).refine( data => data.endDate > data.startDate,{ message:'endDate must be after startDate', path:['endDate']});// URL params — always strings from Express, coerce to numbersconst idParamSchema = z.object({ id: z.coerce.number().int().positive(),});
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),});typeCreateOrderInput= z.infer<typeof createOrderSchema>;// couponCode: string | null — the schema's *output* type, not the input's `string | undefined`
// POST /users { "email": "not-an-email", "name": "" }{"error":"Validation failed","issues":[{"field":"name","message":"Name is required"},{"field":"email","message":"Must be a valid email address"},{"field":"password","message":"Required"}]}
// src/validators/users.schema.tsimport{ z }from'zod';exportconst createUserSchema = z.object({ name: z.string().min(1).max(100).trim(), email: z.string().email().toLowerCase().trim(), password: z.string().min(8).max(128), role: z.enum(['user','admin']).default('user'),});exportconst 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/controllersexporttypeCreateUserInput= z.infer<typeof createUserSchema>;exporttypeUpdateUserInput= z.infer<typeof updateUserSchema>;
// Without wrapper — repeated 50+ times across controllersexportasyncfunctioncreateUser(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 catchexportconst createUser =asyncHandler(async(req, res)=>{const user =await usersService.create(req.body); res.status(201).json(user);});
// 400 — validation failure{"error":"Validation failed","issues":[{"field":"email","message":"Must be a valid email address"},{"field":"items","message":"Order must contain at least one item"}]}// 404 — not found{"error":"Order not found"}// 401 — unauthorized{"error":"Authentication required"}// 409 — conflict{"error":"Email already registered"}// 500 — server error (production){"error":"An unexpected error occurred"}
// src/types/express.d.ts (extend from P-3)declare global {namespace Express {interfaceRequest{ user?:{ id:number; role:string}; requestId?:string;}}}
// In errorHandler.ts, step 5:console.error(`[${newDate().toISOString()}] [${req.requestId}] Unhandled error on ${req.method}${req.path}:`, err);
// src/index.tsprocess.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);});process.on('uncaughtException',(err)=>{console.error('Uncaught exception:', err); process.exit(1);});