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.

Written by Jatin Jain Saraf · Architect of SupraScan

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:

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.

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 }
}

Zod Schema Patterns

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(),
});

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:

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:

// src/middleware/validate.ts
type ValidateTarget = 'body' | 'query' | 'params' | 'headers';
const apiKeyHeaderSchema = z.object({
  'x-api-key': z.string().min(1, 'x-api-key header is required'),
});
 
router.post('/webhooks/stripe', validate(apiKeyHeaderSchema, 'headers'), webhooksController.handle);

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:

// 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:

// 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:

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

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.