typescript 83 lines · 3 tabs

Runtime validation for request bodies (Zod)

Shared by codesnips Jan 2026
3 tabs
import { z } from 'zod';

export const createUserSchema = z
  .object({
    email: z.string().email(),
    name: z.string().min(1).max(120),
    age: z.coerce.number().int().min(13).max(130).optional(),
    role: z.enum(['member', 'admin']).default('member'),
  })
  .strict();

export const listUsersSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().trim().min(1).optional(),
});

export const userIdSchema = z.object({
  id: z.string().uuid(),
});

export type CreateUserInput = z.infer<typeof createUserSchema>;
export type ListUsersQuery = z.infer<typeof listUsersSchema>;
3 files · typescript Explain with highlit

This snippet shows how runtime request validation is layered onto an Express API using Zod, so that untrusted HTTP input is parsed into fully typed values before any handler runs. The core problem is that TypeScript types vanish at runtime: a route typed to receive { email: string } will happily receive undefined or a nested object from a malicious client. Zod closes that gap by describing the shape once and both validating at runtime and inferring the static type from the same source.

In validate middleware, the validate factory accepts a schema keyed by request location (body, query, params) and returns an Express middleware. It runs schema.safeParse rather than parse so a failed parse becomes a structured result.error instead of a thrown exception, which keeps control flow explicit. On success the parsed value is written back onto req, meaning downstream handlers see coerced, defaulted, stripped data rather than the raw payload. On failure it forwards a ValidationError carrying error.flatten().fieldErrors, deferring the actual HTTP response to a central error handler.

The user schemas tab defines the contracts. createUserSchema demonstrates several practical touches: z.string().email() for format checks, z.coerce.number() to turn query strings into numbers, .default() to fill optional fields, and .strict() so unexpected keys are rejected rather than silently ignored. The exported CreateUserInput type is produced with z.infer, so the handler's types stay in lockstep with the validator automatically — changing the schema changes the type, preventing drift.

users router wires it together. Each route lists its validate middleware before the handler, and because validation already ran, the handler treats req.body as trusted and typed. The errorHandler translates a ValidationError into a 422 with per-field messages while letting other errors fall through to a generic 500.

The main trade-off is a small per-request parsing cost and the discipline of keeping schemas as the single source of truth. In return the boundary is airtight: every field entering the system is checked exactly once, at the edge, and the rest of the code can rely on well-formed data. This pattern is the standard reach for any public JSON API where inputs cannot be trusted.


Related snips

Share this code

Here's the card — post it anywhere.

Runtime validation for request bodies (Zod) — share card
Link copied