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.
bash
typescript
Zod Schema Patterns
typescript
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.
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.
The Global Error Handler
One function handles every possible error type:
typescript
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.
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(),});
// src/middleware/validate.tsimport{ Request, Response, NextFunction }from'express';import{ ZodSchema, ZodError }from'zod';typeValidateTarget='body'|'query'|'params';exportfunctionvalidate(schema: ZodSchema, target: ValidateTarget ='body'){return(req: Request, res: Response, next: NextFunction)=>{const result = schema.safeParse(req[target]);if(!result.success){const issues = result.error.issues.map(issue =>({ field: issue.path.join('.'), message: issue.message,}));return res.status(400).json({ error:'Validation failed', issues });}// Replace the raw input with the parsed (and transformed) data req[target]= result.data;next();};}
// 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"}