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();
};
}
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(120),
password: z.string().min(8),
role: z.enum(['member', 'admin']).default('member'),
});
export const userIdParamsSchema = z.object({
id: z.string().uuid(),
});
export const listUsersQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
perPage: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().optional(),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type ListUsersQuery = z.infer<typeof listUsersQuerySchema>;
import { Router, Request, Response, NextFunction } from 'express';
import { validate } from './validate';
import {
createUserSchema,
userIdParamsSchema,
listUsersQuerySchema,
CreateUserInput,
ListUsersQuery,
} from './userSchemas';
import { UserService } from './userService';
export const usersRouter = Router();
usersRouter.get(
'/',
validate({ query: listUsersQuerySchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { page, perPage, search } = req.query as unknown as ListUsersQuery;
const result = await UserService.list({ page, perPage, search });
res.json(result);
} catch (err) {
next(err);
}
}
);
usersRouter.post(
'/',
validate({ body: createUserSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
const input = req.body as CreateUserInput;
const user = await UserService.create(input);
res.status(201).json(user);
} catch (err) {
next(err);
}
}
);
usersRouter.delete(
'/:id',
validate({ params: userIdParamsSchema }),
async (req: Request, res: Response, next: NextFunction) => {
try {
await UserService.remove(req.params.id);
res.status(204).end();
} catch (err) {
next(err);
}
}
);
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
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous 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
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
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
Share this code
Here's the card — post it anywhere.