typescript 117 lines · 3 tabs

Composable Express Request Validation with Zod Schema Middleware

Shared by codesnips Aug 2026
3 tabs
import { RequestHandler } from 'express';
import { ZodTypeAny, ZodError } from 'zod';

export class ValidationError extends Error {
  status = 422;
  constructor(public issues: unknown) {
    super('Request validation failed');
    this.name = 'ValidationError';
  }
}

type Schemas = {
  body?: ZodTypeAny;
  query?: ZodTypeAny;
  params?: ZodTypeAny;
};

export function validate(schemas: Schemas): RequestHandler {
  const segments = Object.keys(schemas) as (keyof Schemas)[];

  return (req, _res, next) => {
    const errors: Record<string, ZodError['issues']> = {};

    for (const key of segments) {
      const schema = schemas[key];
      if (!schema) continue;

      const result = schema.safeParse(req[key]);
      if (result.success) {
        // overwrite with parsed + coerced values
        (req as any)[key] = result.data;
      } else {
        errors[key] = result.error.issues;
      }
    }

    if (Object.keys(errors).length > 0) {
      return next(new ValidationError(errors));
    }

    next();
  };
}
3 files · typescript Explain with highlit

This snippet shows how request validation in an Express app can be factored out of controllers and into a single reusable middleware driven by declarative schemas. The goal is to keep controllers focused on business logic while guaranteeing that anything reaching them has already been parsed and coerced into a known shape.

In validate middleware, validate is a higher-order function: it accepts a schema object describing the body, query, and params and returns an Express middleware. Each present segment is run through its schema's safeParse, which either yields typed, coerced data or a structured error. Critically, the middleware writes the parsed output back onto req.body, req.query, and req.params, so downstream handlers see the sanitized version rather than raw input. This is where coercion pays off — a query string "2" becomes the number 2 before the controller ever runs.

Errors are not thrown ad hoc. When any segment fails, the collected issues are packaged into a ValidationError with a 422 status and passed to next(err), deferring all response formatting to the central error handler. This avoids scattering res.status(400).json(...) calls across every route and keeps error shapes consistent.

In user schemas, the schemas are plain Zod objects colocated by resource. createUserSchema demonstrates real constraints — email validation, a password minimum, and an optional enum with a default — while listUsersQuerySchema uses z.coerce.number so pagination params arrive as real numbers. Defining schemas separately means they can also be reused for OpenAPI generation or client-side checks.

In users routes + controller, validate is composed into the route chain per endpoint, so each handler declares exactly what it expects. Because validation already ran, createUser can treat req.body as trusted and typed. The trade-off is that this pattern centralizes trust in the middleware: any route that forgets to attach validate gets no protection, so teams often enforce it via linting or a route wrapper. The payoff is DRY, self-documenting endpoints and a single place to evolve validation behavior.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
class SignupForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :account_name, :string
  attribute :email, :string

Shallow Controller, Deep Params: Form Object Pattern

rails activemodel form-object
by codesnips 3 tabs
javascript
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
  const success = true;

  setTimeout(() => {
    if (success) {

Promises and async/await patterns for asynchronous JavaScript

javascript promises async-await
by Alex Chang 1 tab
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab
typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface FilterState {
  search: string
  category: string | null

Zustand for lightweight state management

react zustand state-management
by Maya Patel 2 tabs

Share this code

Here's the card — post it anywhere.

Composable Express Request Validation with Zod Schema Middleware — share card
Link copied