Request Validation as Middleware

Turn a schema into a reusable validate() middleware so handlers only ever see clean, typed input.

Validation belongs at the edge, before your controller runs, so that by the time your business logic executes it can trust its inputs completely. The cleanest way to express this in Express is a validate(schema) factory that parses req.body, req.query and req.params, replaces them with the parsed (coerced, defaulted) values, and short-circuits with a 400 on failure.

Why replace, not just check

A good schema library does more than yes/no — it coerces ("42" to 42), applies defaults, and strips unknown keys. Assigning the parsed result back onto req means your handler reads req.body.page as a real number, not a string.

const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ errors: parsed.error.issues });
req.body = parsed.data; // hand the clean data downstream

Express 5 note

In Express 5 req.query is a getter and is no longer writable in place, so validate the query and stash the clean copy on req.validatedQuery (or read from the returned data) rather than reassigning req.query.

Example

Example · javascript
// middleware/validate.js
import { z } from 'zod';

// Accepts a map of the request parts you care about.
export function validate({ body, query, params } = {}) {
  return (req, res, next) => {
    const errors = [];

    const run = (schema, value, where) => {
      if (!schema) return value;
      const r = schema.safeParse(value);
      if (!r.success) {
        errors.push(...r.error.issues.map((i) => ({ where, path: i.path, message: i.message })));
        return value;
      }
      return r.data;
    };

    const cleanBody = run(body, req.body, 'body');
    const cleanQuery = run(query, req.query, 'query');   // don't reassign req.query in v5
    const cleanParams = run(params, req.params, 'params');

    if (errors.length) return res.status(400).json({ error: 'ValidationError', errors });

    req.body = cleanBody;
    req.validatedQuery = cleanQuery;  // Express 5: req.query is read-only
    req.params = cleanParams;
    next();
  };
}

// schemas/orders.js
export const listOrders = {
  query: z.object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
  }),
};

// routes
// router.get('/', validate(listOrders), orders.list);

When to use it

  • A team uses a reusable validate middleware factory with Zod schemas to validate request bodies, query strings, and path parameters consistently across all routes.
  • A developer chains express-validator rules defined in a separate validations/ file and imports them alongside controllers to keep route files tidy.
  • A multi-step form's API validates each step's payload against a different Joi schema before saving partial data to the session.

More examples

Zod request validator middleware

A validateBody factory parses req.body with a Zod schema, returning issues on failure and replacing req.body with the coerced data on success.

Example · js
const { z } = require('zod');

const createUser = z.object({
  name:  z.string().min(2),
  email: z.string().email(),
  age:   z.number().int().min(0).optional(),
});

function validateBody(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) return res.status(400).json({ errors: result.error.issues });
    req.body = result.data; // coerced and typed
    next();
  };
}

app.post('/users', validateBody(createUser), ctrl.create);

Validating query params with Joi

Validates and applies Joi defaults to req.query so the controller always receives a validated page and limit.

Example · js
const Joi = require('joi');

const paginationSchema = Joi.object({
  page:  Joi.number().integer().min(1).default(1),
  limit: Joi.number().integer().min(1).max(100).default(20),
});

function validateQuery(schema) {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.query);
    if (error) return res.status(400).json({ error: error.message });
    req.query = value; // apply defaults
    next();
  };
}

app.get('/products', validateQuery(paginationSchema), ctrl.list);

express-validator rule files

Keeps express-validator rule arrays in a dedicated validations/ file so route files only import named rule sets.

Example · js
// validations/post.js
const { body } = require('express-validator');
exports.createPost = [
  body('title').trim().isLength({ min: 5, max: 200 }),
  body('content').trim().isLength({ min: 20 }),
  body('tags').isArray().optional(),
];

// routes/posts.js
const { createPost } = require('../validations/post');
const { validationResult } = require('express-validator');
router.post('/', createPost, (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
  res.status(201).json(req.body);
});

Discussion

  • Be the first to comment on this lesson.