Validation Middleware

A Reusable Validation Middleware

Calling safeParse inside every handler repeats the same eight lines per route. Wrap it once in a factory that takes a schema per request part, checks them all, and either attaches the parsed values or hands one error to next.

validate.js — one factory for body, query and paramsJavaScript
import { ValidationError } from './errors.js';
export const validate = (schemas) => (req, res, next) => {
  const details = [];
  for (const [part, schema] of Object.entries(schemas)) {
    const r = schema.safeParse(req[part]);
    if (r.success) {
      const key = 'valid' + part[0].toUpperCase() + part.slice(1);
      Object.defineProperty(req, key, { value: r.data, enumerable: true });
    } else details.push(...r.error.issues.map((i) => ({
      in: part, field: i.path.join('.') || '(root)', code: i.code, message: i.message })));
  }
  return details.length ? next(new ValidationError(details)) : next();
};

Three decisions are worth naming. Every part is checked before anything is reported, so a request with a bad body and a bad limit gets one response listing both. The issues are flattened into a client-facing shape — in, field, code, message — instead of leaking Zod 44,027 's issue objects, so swapping the library later does not change your public contract. And parsed values land on new properties rather than over the originals, which is not stylistic: in Express 5 24,430 req.query is a getter with no setter, so the Express 4 habit of writing the coerced object back throws TypeError: Cannot set property query of #<IncomingMessage> which has only a getter.

A route now declares the shape it accepts and reads the parsed copy: app.get('/api/books/:id', validate({ params: idParam }), handler) with idParam = z.object({ id: z.coerce.number().int().positive() }) gives the handler a req.validParams.id that is a number, so books.get(7) finds the entry that books.get("7") would have missed. With listQuery from Schema Validation with Zod on the collection route, GET /api/books?limit=abc never reaches its handler at all:

Output of 48
{"error":{"code":"validation_failed","message":"Request validation failed","details":
[{"in":"query","field":"limit","code":"invalid_type",
  "message":"Invalid input: expected number, received NaN"}]},"requestId":"r-4711"}