Skip to main content

Runtime Validation with Zod

TypeScript guarantees evaporate at the network boundary: the compiler trusts that req.body matches your interface, but the runtime receives whatever a client actually sent. Zod closes that gap by making one schema serve as both the runtime guard and the source of your static types. This guide is part of Schema Design & Validation Patterns, and it walks through building a production validation layer with Zod 3.23 — strict object schemas, safeParse middleware, discriminated unions, transforms and refinements, stable error formatting, and the performance choices that keep validation off your latency budget.

When to Use This Approach

Reach for Zod when these conditions hold:

  • You ship TypeScript end to end and want a single artifact that is both the runtime validator and the inferred type — no hand-maintained interface drifting from the validator.
  • You validate untrusted input at a boundary: HTTP request bodies, query strings, webhook payloads, message-queue events, third-party API responses, or environment variables.
  • You need expressive, composable rules — unions, conditional refinements, coercion, defaults — that a JSON Schema keyword set struggles to express ergonomically.
  • Your team prefers a code-first workflow over maintaining a separate spec file by hand.

Consider alternatives when you are maintaining an established Joi or Yup codebase — see Joi and Yup for Legacy Systems — or when your contract is owned upstream as OpenAPI and you would rather generate types from it, covered in Compile-Time Type Generation from OpenAPI. Zod validates at runtime inside your service; it is not a wire-format spec your consumers read.

Prerequisites

This guide targets Zod 3.23 and TypeScript 5.x. Zod requires strict mode in your compiler configuration — inference is unreliable without it.

npm install zod@3.23
npm install --save-dev typescript@5
// tsconfig.json — the non-negotiable flags
{
  "compilerOptions": {
    "strict": true,          // Zod inference depends on this
    "target": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

A quick sanity check that the install resolved and types flow:

import { z } from 'zod';

const Ping = z.object({ ok: z.literal(true) });
type Ping = z.infer<typeof Ping>;   // { ok: true }
console.log(Ping.safeParse({ ok: true }).success); // true

Step 1: Define Strict Schemas with Type Inference

Start with the schema, not the type. A Zod schema is the single source of truth; you derive the static type from it with z.infer, so the two can never drift. Chain .strict() on every object you want sealed — by default Zod strips unknown keys silently, which hides payload drift instead of surfacing it.

import { z } from 'zod';

// Define once, at module scope (never inside a handler).
export const CreateUserSchema = z
  .object({
    email: z.string().email(),
    displayName: z.string().min(2).max(80),
    role: z.enum(['admin', 'editor', 'viewer']),
    // .optional() => key may be absent; .nullable() => value may be null.
    // Zod does NOT conflate the two, unlike some legacy validators.
    bio: z.string().max(500).optional(),
    metadata: z.record(z.string(), z.unknown()).optional(),
  })
  .strict(); // reject unknown top-level keys instead of stripping them

// The inferred type is the OUTPUT type (post-transform). Keep it exported
// so request handlers and service code share one definition.
export type CreateUserInput = z.infer<typeof CreateUserSchema>;

z.infer gives you the output type — the shape after any coercion or transform runs. When the pre-parse shape differs (for example a field declared with z.coerce.number() that arrives as a string), use z.input<typeof Schema> for the incoming side and z.output (the alias for z.infer) for the parsed side. Knowing which one you need prevents a whole class of confusing assignment errors.

Three rules keep schemas honest:

  • .strict() does not recurse. It only seals the object it is attached to. Apply it to nested z.object() calls too, or model deep structures deliberately — see Handling Complex Nested Objects in API Schemas for depth and recursion strategy.
  • Distinguish .optional() (key may be missing) from .nullable() (value may be null). Combine as .nullish() when both are valid.
  • Avoid .passthrough() on boundary schemas. It re-admits unknown keys into your typed data and defeats the point of validation.

Step 2: Wrap Validation in Middleware with safeParse

Validation belongs at the edge of the request lifecycle, before any business logic runs. A generic middleware factory takes a schema and returns a handler that parses, short-circuits on failure, and replaces the raw input with the parsed (and typed) data on success.

Use .safeParse() rather than .parse(). safeParse returns a discriminated result — { success: true, data } or { success: false, error } — so you decide the HTTP status and body instead of catching a thrown exception in error-handling middleware.

import type { Request, Response, NextFunction } from 'express';
import type { ZodTypeAny } from 'zod';

// Generic over the schema so req.body is correctly typed downstream.
export const validateBody =
  <T extends ZodTypeAny>(schema: T) =>
  (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      // formatZodError is defined in Step 5.
      return res.status(400).json(formatZodError(result.error));
    }
    req.body = result.data; // parsed, coerced, and strict-checked
    next();
  };

// Usage — schema is built once, reused per request:
// router.post('/users', validateBody(CreateUserSchema), createUserHandler);

For the complete Express wiring — typing the request object, validating params and headers, and ordering middleware correctly — follow implementing Zod validation in Express.js routes.

Prefer the synchronous safeParse for ordinary bodies; it does not allocate a microtask. Switch to safeParseAsync only when a schema contains an async .refine() or .transform() — calling the sync method on an async schema throws at runtime.

Validating query params and env vars

Query strings and environment variables arrive as strings, so coercion is mandatory. Validate them with their own schemas — never reuse a body schema for a query string.

// Query params: everything is a string until coerced.
export const ListQuery = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  includeArchived: z.coerce.boolean().default(false),
});

// Environment: validate ONCE at startup and fail fast.
const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.string().url(),
});

const parsedEnv = EnvSchema.safeParse(process.env);
if (!parsedEnv.success) {
  console.error('Invalid environment:', parsedEnv.error.flatten().fieldErrors);
  process.exit(1); // never start a misconfigured process
}
export const env = parsedEnv.data;

The pagination shape above pairs naturally with the patterns in Pagination and Filtering Schema Patterns. For the full treatment of coercion edge cases and startup validation, see validating query params and env vars with Zod.

Step 3: Model Variants with Discriminated Unions

When a payload can take several shapes distinguished by a tag field — event types, payment methods, notification channels — model it with z.discriminatedUnion(). Unlike a plain z.union(), which tries each member and aggregates errors, the discriminated form reads the tag once and jumps straight to the matching member. That is both faster and produces a clear, single-branch error.

// Each member declares the discriminator as a non-optional z.literal.
export const NotificationSchema = z.discriminatedUnion('channel', [
  z.object({
    channel: z.literal('email'),
    to: z.string().email(),
    subject: z.string().min(1),
  }),
  z.object({
    channel: z.literal('sms'),
    to: z.string().regex(/^\+[1-9]\d{6,14}$/), // E.164
    body: z.string().max(160),
  }),
  z.object({
    channel: z.literal('webhook'),
    url: z.string().url(),
    secret: z.string().min(16),
  }),
]);

export type Notification = z.infer<typeof NotificationSchema>;

The single most common failure here is a discriminator that is missing, optional, or not a literal on one member — Zod then cannot build the lookup map and rejects otherwise-valid input. If you hit Invalid discriminator value or a union that refuses a correct object, the fix walkthrough is in fixing Zod discriminated union mismatches. For the design-level view of tagged polymorphism across schemas, Handling Complex Nested Objects in API Schemas covers when to reach for discriminators versus oneOf.

The data flow below shows where each piece sits in a single request.

Zod request validation flow An inbound request enters validation middleware, which runs safeParse against a strict schema. On success the parsed data reaches the handler; on failure a formatted 400 error is returned. Inbound Request untrusted JSON Middleware schema.safeParse (req.body) success? Handler typed result.data 400 Error formatZodError yes no

Step 4: Apply Transforms and Refinements

Validation is not only “is this shape correct” — it is also “normalize it and prove cross-field invariants.” Zod gives you .transform() to reshape parsed data and .refine() / .superRefine() to assert rules the type system cannot express.

const DateRangeSchema = z
  .object({
    // .transform runs AFTER the field validates; output type changes to Date.
    start: z.string().datetime().transform((s) => new Date(s)),
    end: z.string().datetime().transform((s) => new Date(s)),
    note: z
      .string()
      .trim()                     // normalize before length checks
      .transform((s) => s.toLowerCase())
      .optional(),
  })
  // .refine for cross-field rules; attach path so the error targets a field.
  .refine((data) => data.start < data.end, {
    message: 'start must be before end',
    path: ['end'],
  });

// z.input is { start: string; end: string; ... }
// z.infer (output) is { start: Date; end: Date; ... }
type DateRangeOut = z.infer<typeof DateRangeSchema>;

Key behaviors to internalize:

  • A .transform() changes the output type, so z.infer reflects the transformed shape while z.input keeps the raw shape. This is exactly when the input/output distinction matters.
  • .refine() runs after the base type validates. For multiple related checks or to add several issues at once, use .superRefine((val, ctx) => ctx.addIssue(...)).
  • An async refinement (for example a uniqueness check against a database) forces you to call safeParseAsync. Keep async refinements out of hot paths where you can.

Step 5: Format Errors into a Stable Contract

A raw ZodError is verbose and unstable as an API surface. Flatten it into a predictable JSON body so clients get field-keyed, machine-readable feedback and never see internal stack traces. Align the envelope with your service-wide standard documented in Designing Robust Error Response Contracts.

import { z } from 'zod';

export const formatZodError = (error: z.ZodError) => {
  // .flatten() splits issues into formErrors (top-level) and fieldErrors.
  const { fieldErrors, formErrors } = error.flatten();
  return {
    code: 'VALIDATION_FAILED',
    message: 'Request payload failed validation',
    formErrors,                  // issues not tied to a specific field
    fieldErrors: Object.entries(fieldErrors).map(([field, issues]) => ({
      field,
      issues: issues ?? [],
    })),
  };
};

For nested paths, error.flatten() only goes one level deep; use error.format() when you need the full nested tree, or error.issues for the flat array with full path arrays. Pick one shape and keep it stable across services — consumers will code against it.

Spec / Schema Reference

The Zod APIs you will reach for most when building a validation layer:

API Type Default behavior Effect
.strict() object modifier objects strip unknown keys Rejects unknown keys with an error instead of stripping (does not recurse)
.passthrough() object modifier strip unknown keys Keeps unknown keys on the output (avoid at boundaries)
.safeParse(x) method n/a Returns { success, data } or { success, error }; never throws
.safeParseAsync(x) method n/a Async variant required when schema has async refine/transform
.parse(x) method n/a Returns data or throws ZodError; for trusted internal code
z.infer<T> type util n/a Output type after transforms (alias of z.output)
z.input<T> type util n/a Pre-parse input type (before coercion/transform)
z.coerce.* constructor strict type check Coerces input (e.g. string to number) before validating
.optional() modifier required Key may be absent (T | undefined)
.nullable() modifier non-null Value may be null (T | null)
.default(v) modifier n/a Substitutes v when the input is undefined
z.discriminatedUnion(k, [...]) constructor n/a Fast tagged union keyed on a literal discriminator
.refine(fn, opts) method n/a Custom predicate; set path to target a field
.transform(fn) method n/a Reshapes parsed value; changes the output type
error.flatten() method n/a { formErrors, fieldErrors }, one level deep
error.format() method n/a Full nested error tree mirroring the schema shape

Verification

Prove the validation layer works before it ships. A small test suite is enough to lock the contract and catch regressions.

import { describe, it, expect } from 'vitest';
import { CreateUserSchema } from './schemas';

describe('CreateUserSchema', () => {
  it('accepts a valid payload', () => {
    const r = CreateUserSchema.safeParse({
      email: 'a@b.com', displayName: 'Ada', role: 'admin',
    });
    expect(r.success).toBe(true);
  });

  it('rejects unknown keys under .strict()', () => {
    const r = CreateUserSchema.safeParse({
      email: 'a@b.com', displayName: 'Ada', role: 'admin', isAdmin: true,
    });
    expect(r.success).toBe(false); // "Unrecognized key(s)"
  });
});
$ npx vitest run
 ✓ CreateUserSchema > accepts a valid payload
 ✓ CreateUserSchema > rejects unknown keys under .strict()

 Test Files  1 passed (1)
      Tests  2 passed (2)

Gate the contract in CI so schema drift cannot merge. The type-check catches inference breaks; the test run catches behavioral regressions.

name: Validation Contract
on:
  pull_request:
    branches: [main]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npx tsc --noEmit   # inference must still hold
      - run: npx vitest run     # schema behavior must still hold

Troubleshooting

Unrecognized key(s) in object on a payload you expected to pass. A .strict() schema received a key it does not declare. Either the client is sending an extra field (the correct rejection) or your schema is missing a legitimate field — add it explicitly. Do not reach for .passthrough() to silence this; that re-opens the hole .strict() exists to close.

Expected string, received undefined even though the field is “optional.” You used .nullable() where the value is actually absent, or vice versa. .optional() permits a missing key; .nullable() permits an explicit null. Use .nullish() when both should pass.

Async refinement encountered during synchronous parse. The schema contains an async .refine() or .transform() but you called safeParse. Switch that call site to safeParseAsync, and verify whether the async check truly needs to run on the request hot path.

Invalid discriminator value or a discriminated union rejecting valid data. One union member declares the discriminator as optional, as a non-literal, or omits it. Make the discriminator a non-optional z.literal on every member. The detailed diagnosis is in fixing Zod discriminated union mismatches.

Validation latency spikes under load. Schemas are being rebuilt inside the request handler, or a deep z.union() is brute-forcing every branch. Move schema construction to module scope, replace plain unions with z.discriminatedUnion, and if you are weighing libraries on a tight budget, consult Zod vs Joi vs Yup performance benchmarks.

Frequently Asked Questions

Does z.infer give me the input type or the output type?

z.infer returns the output type — the shape after transforms and coercion run. Use z.input when you need the pre-parse shape, for example the raw request body type before z.coerce.number() turns a string into a number.

Should I use parse or safeParse in request handlers?

Use safeParse in handlers. It returns a discriminated result object instead of throwing, so you control the HTTP status and error body explicitly. Reserve parse for trusted internal code where a thrown ZodError is acceptable.

Why does my discriminated union reject a valid object?

Almost always the discriminator key is missing, optional, or not a literal on one of the union members. Every member must define the discriminator as a non-optional z.literal, and the runtime value must match one literal exactly.

Is Zod fast enough for high-throughput APIs?

Yes for typical payloads. Define schemas once at module scope so they are not rebuilt per request, prefer safeParse over safeParseAsync unless you have async refinements, and avoid deep unions on hot paths. For sub-millisecond budgets on huge payloads, benchmark against the alternatives.

How do I validate environment variables with Zod?

Define a z.object over process.env, use z.coerce for numeric and boolean vars, and call safeParse at startup. Fail fast and exit if validation fails so misconfiguration never reaches request handling.

Does .strict() recurse into nested objects?

No. .strict() only rejects unknown keys on the object it is called on. Apply .strict() to every nested z.object you want locked down, or build them strict from the start.