Installation and the Core Mental Model

# Zod 3.x — production stable
npm install zod

The core concept is a schema. A schema both describes a shape (like a TypeScript type) and contains a .parse() method that validates data at runtime. If validation passes, you get back the parsed value with the correct TypeScript type. If it fails, you get a detailed ZodError:

import { z } from 'zod';

const UserSchema = z.object({
  id:    z.string().uuid(),
  name:  z.string().min(1).max(100),
  email: z.string().email(),
  age:   z.number().int().min(0).max(150).optional(),
  role:  z.enum(['admin', 'editor', 'viewer']),
});

// TypeScript type inferred automatically — no duplication
type User = z.infer<typeof UserSchema>;

// Runtime validation
const user = UserSchema.parse(req.body); // throws ZodError if invalid
const result = UserSchema.safeParse(req.body); // returns { success, data } or { success, error }

parse vs safeParse

Always use safeParse in production code — it never throws, so you can handle errors gracefully:

const result = UserSchema.safeParse(req.body);

if (!result.success) {
  // result.error is a ZodError with full issue details
  const fieldErrors = result.error.flatten().fieldErrors;
  return res.status(422).json({
    error: { code: 'VALIDATION_FAILED', details: fieldErrors }
  });
}

// result.data is fully typed as User
const user: User = result.data;

String Refinements

MethodWhat it validates
.min(n)Minimum string length
.max(n)Maximum string length
.email()Valid email format
.url()Valid URL
.uuid()Valid UUID v4
.datetime()ISO 8601 datetime string
.regex(pattern)Custom regex pattern
.startsWith(s)Must begin with prefix
.trim()Transform: whitespace stripped
.toLowerCase()Transform: lowercase output

Transformations: Parse, Don't Just Validate

Zod schemas can transform data during parsing — coercing types, normalising values, and reshaping objects. This is the "parse, don't validate" philosophy:

const DateSchema = z.string()
  .datetime()
  .transform(str => new Date(str));
// Input: "2026-06-07T10:30:00Z" → Output: Date object

const SearchSchema = z.object({
  q:     z.string().trim().min(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  page:  z.coerce.number().int().min(1).default(1),
});
// z.coerce converts "25" (query param string) → 25 (number)

Object Methods: pick, omit, partial, extend

const CreateUserSchema = UserSchema.omit({ id: true });
// For POST /users — id is server-generated, not in request body

const UpdateUserSchema = UserSchema.partial();
// All fields optional — for PATCH requests

const PublicUserSchema = UserSchema.pick({ id: true, name: true });
// Only expose safe fields to public API consumers

const UserWithTimestamps = UserSchema.extend({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});
// Add fields to existing schema without mutating it

Discriminated Unions

For polymorphic data (different shapes based on a type tag), use discriminatedUnion — it's faster than union because Zod can skip branches based on the discriminator key:

const NotificationSchema = z.discriminatedUnion('type', [
  z.object({
    type:    z.literal('email'),
    to:      z.string().email(),
    subject: z.string(),
    body:    z.string(),
  }),
  z.object({
    type:    z.literal('sms'),
    phone:   z.string().regex(/^\+[1-9]\d{6,14}$/),
    message: z.string().max(160),
  }),
  z.object({
    type:  z.literal('push'),
    token: z.string(),
    title: z.string(),
    body:  z.string().max(256),
  }),
]);

Custom Validators with refine

const PasswordSchema = z.object({
  password:        z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  data => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'],  // error appears on this field
  }
);

const PositiveAmount = z.number().refine(
  n => n > 0 && Number.isFinite(n),
  { message: 'Must be a positive finite number' }
);
✅ Where to Put Zod in Your Stack
  • API route handlers: Parse and validate req.body, req.query, req.params at every entry point
  • Environment variables: Parse process.env at startup — crash fast if required config is missing
  • localStorage / external API responses: Any data from outside your process boundary
  • Form submissions: Use React Hook Form's Zod resolver for end-to-end type-safe forms

Zod vs JSON Schema vs TypeScript Types

DimensionTypeScript typesJSON SchemaZod
Runtime validationNo — erased at compileYes (via library)Yes — built-in
TypeScript inferenceYes — it's TSNo (manual or generated)Yes — z.infer<>
TransformationsNoNoYes — .transform()
Language supportJS/TS onlyEvery languageJS/TS only
Best forCompile-time contractsCross-language APIsTS projects, form/API validation