Back/Module P-4 Input Validation, Error Handling, and Middleware Pipelines
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
export async function createUser(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 }

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
npm install zod
typescript
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 } }

Zod Schema Patterns

typescript
import { z } from 'zod'; // String refinements const emailSchema = z.string() .email() .toLowerCase() // transform to lowercase before storing .trim(); // strip whitespace // Numbers const priceSchema = z.number() .positive('Price must be positive') .multipleOf(0.01, 'Price must have at most 2 decimal places'); // Optionals and defaults const 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), }); // Arrays const 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 types const 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 validation const 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 numbers const idParamSchema = z.object({ id: z.coerce.number().int().positive(), });

The Validate Middleware

Wrap Zod parsing into a reusable Express middleware factory:

typescript
// src/middleware/validate.ts import { Request, Response, NextFunction } from 'express'; import { ZodSchema, ZodError } from 'zod'; type ValidateTarget = 'body' | 'query' | 'params'; export function validate(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(); }; }

Usage — schemas run before the controller, controller gets clean validated data:

typescript
// src/routes/users.routes.ts import { validate } from '../middleware/validate.js'; import { createUserSchema, updateUserSchema } from '../validators/users.schema.js'; import { idParamSchema } from '../validators/common.schema.js'; router.post('/', validate(createUserSchema), usersController.createUser); router.patch('/:id', validate(idParamSchema, 'params'), validate(updateUserSchema), usersController.updateUser, );

When validation fails, the middleware returns before the controller is called:

json
// 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" } ] }

Organising Your Schemas

Keep schemas next to the routes that use them:

src/
├── validators/
│   ├── common.schema.ts     # idParamSchema, paginationSchema
│   ├── users.schema.ts      # createUserSchema, updateUserSchema
│   ├── posts.schema.ts      # createPostSchema, updatePostSchema
│   └── orders.schema.ts     # createOrderSchema
typescript
// src/validators/users.schema.ts import { z } from 'zod'; export const 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'), }); 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>;
typescript
// src/validators/common.schema.ts import { z } from 'zod'; export const idParamSchema = z.object({ id: z.coerce.number().int().positive(), }); export const paginationSchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(20), });

The Async Wrapper: Eliminating try/catch Boilerplate

Every async route handler has the same try/catch wrapper. Remove it entirely:

typescript
// 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); });

The wrapper:

typescript
// src/utils/asyncHandler.ts import { Request, Response, NextFunction, RequestHandler } from 'express'; type AsyncFn = (req: Request, res: Response, next: NextFunction) => Promise<unknown>; export function asyncHandler(fn: AsyncFn): RequestHandler { return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; }

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
// src/controllers/users.controller.ts import { asyncHandler } from '../utils/asyncHandler.js'; import * as usersService from '../services/users.service.js'; export const createUser = asyncHandler(async (req, res) => { const user = await usersService.create(req.body); res.status(201).json(user); }); export const getUserById = asyncHandler(async (req, res) => { const user = await usersService.findById(req.params.id); res.json(user); }); export const updateUser = asyncHandler(async (req, res) => { const user = await usersService.update(req.params.id, req.body, req.user!.id); res.json(user); }); export const deleteUser = asyncHandler(async (req, res) => { await usersService.delete(req.params.id, req.user!.id); res.status(204).send(); });

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
// src/errors/AppError.ts export class AppError extends Error { readonly statusCode: number; readonly isOperational: boolean; // false = programming error, not safe to expose constructor(message: string, statusCode = 500, isOperational = true) { super(message); this.name = this.constructor.name; this.statusCode = statusCode; this.isOperational = isOperational; Error.captureStackTrace(this, this.constructor); } } // 400 — request is malformed or missing data export class ValidationError extends AppError { readonly issues?: Array<{ field: string; message: string }>; constructor(message: string, issues?: Array<{ field: string; message: string }>) { super(message, 400); this.issues = issues; } } // 401 — not authenticated export class UnauthorizedError extends AppError { constructor(message = 'Authentication required') { super(message, 401); } } // 403 — authenticated but not allowed export class ForbiddenError extends AppError { constructor(message = 'Insufficient permissions') { super(message, 403); } } // 404 — resource does not exist export class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); } } // 409 — conflict (duplicate email, insufficient stock, etc.) export class ConflictError extends AppError { constructor(message: string) { super(message, 409); } } // 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.


The Global Error Handler

One function handles every possible error type:

typescript
// src/middleware/errorHandler.ts import { Request, Response, NextFunction } from 'express'; import { ZodError } from 'zod'; import { Prisma } from '@prisma/client'; import { AppError, ValidationError } from '../errors/AppError.js'; export function errorHandler( err: unknown, req: Request, res: Response, next: NextFunction, // required 4th param — Express detects error handlers by arity ): void { // ── 1. Zod validation errors ────────────────────────────────────────────── if (err instanceof ZodError) { const issues = err.issues.map(issue => ({ field: issue.path.join('.'), message: issue.message, })); res.status(400).json({ error: 'Validation failed', issues }); return; } // ── 2. Our custom application errors ────────────────────────────────────── if (err instanceof AppError) { const body: Record<string, unknown> = { error: err.message }; if (err instanceof ValidationError && err.issues) { body.issues = err.issues; } res.status(err.statusCode).json(body); return; } // ── 3. Prisma known errors ───────────────────────────────────────────────── if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err.code === 'P2025') { res.status(404).json({ error: 'Record not found' }); return; } if (err.code === 'P2002') { const fields = (err.meta?.target as string[])?.join(', ') ?? 'field'; res.status(409).json({ error: `Duplicate value for ${fields}` }); return; } if (err.code === 'P2003') { res.status(400).json({ error: 'Referenced record does not exist' }); return; } } // ── 4. JWT errors (if not using our UnauthorizedError wrapper) ───────────── if (err instanceof Error) { if (err.name === 'JsonWebTokenError') { res.status(401).json({ error: 'Invalid token' }); return; } if (err.name === 'TokenExpiredError') { res.status(401).json({ error: 'Token expired' }); return; } } // ── 5. Unknown errors — log and return generic 500 ────────────────────────── console.error(`[${new Date().toISOString()}] Unhandled error on ${req.method} ${req.path}:`, err); res.status(500).json({ error: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : (err instanceof Error ? err.message : String(err)), }); }

Register it after all routes in index.ts:

typescript
// 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'; const app = express(); app.use(express.json()); app.use('/users', usersRouter); app.use('/posts', postsRouter); // 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.


Putting It All Together: A Complete Route

Here is a full POST /orders route with validation, auth, and async error handling — no boilerplate:

typescript
// src/routes/orders.routes.ts import { Router } from 'express'; import { validate } from '../middleware/validate.js'; import { authenticate } from '../middleware/auth.js'; import { createOrderSchema } from '../validators/orders.schema.js'; import { createOrder, getOrderById, listOrders } from '../controllers/orders.controller.js'; const router = Router(); router.post('/', authenticate, validate(createOrderSchema), createOrder); router.get('/', authenticate, validate(paginationSchema, 'query'), listOrders); router.get('/:id', authenticate, validate(idParamSchema, 'params'), getOrderById); export default router;
typescript
// src/controllers/orders.controller.ts import { asyncHandler } from '../utils/asyncHandler.js'; import * as ordersService from '../services/orders.service.js'; export const createOrder = asyncHandler(async (req, res) => { const order = await ordersService.createOrder({ userId: req.user!.id, items: req.body.items, couponCode: req.body.couponCode, }); res.status(201).json(order); }); export const getOrderById = asyncHandler(async (req, res) => { const order = await ordersService.getOrderById(req.params.id, req.user!.id); res.json(order); }); export const listOrders = asyncHandler(async (req, res) => { const orders = await ordersService.listOrdersByUser(req.user!.id, req.query); res.json(orders); });
typescript
// src/services/orders.service.ts import { NotFoundError, ConflictError, ForbiddenError } from '../errors/AppError.js'; import * as ordersRepo from '../repositories/orders.repository.js'; export async function createOrder({ userId, items, couponCode }) { // ... business logic throws typed errors const product = await productsRepo.findById(item.productId); if (!product) throw new NotFoundError('Product'); if (product.stock < item.quantity) throw new ConflictError(`Insufficient stock for ${product.name}`); return ordersRepo.create({ userId, total, items: orderItems }); } 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.


Consistent Error Response Shape

Define the shape once in your API documentation and stick to it:

json
// 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" }

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
// src/middleware/requestId.ts import { Request, Response, NextFunction } from 'express'; import { randomUUID } from 'crypto'; export function requestId(req: Request, res: Response, next: NextFunction) { req.requestId = req.headers['x-request-id'] as string ?? randomUUID(); res.setHeader('x-request-id', req.requestId); next(); }
typescript
// src/types/express.d.ts (extend from P-3) declare global { namespace Express { interface Request { user?: { id: number; role: string }; requestId?: string; } } }

Include the request ID in error logs:

typescript
// In errorHandler.ts, step 5: console.error(`[${new Date().toISOString()}] [${req.requestId}] Unhandled error on ${req.method} ${req.path}:`, err);

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.

Discussion