Installation and the Core Mental Model
# Zod 3.x — production stable
npm install zodThe 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
| Method | What 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 itDiscriminated 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' }
);- API route handlers: Parse and validate
req.body,req.query,req.paramsat every entry point - Environment variables: Parse
process.envat 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
| Dimension | TypeScript types | JSON Schema | Zod |
|---|---|---|---|
| Runtime validation | No — erased at compile | Yes (via library) | Yes — built-in |
| TypeScript inference | Yes — it's TS | No (manual or generated) | Yes — z.infer<> |
| Transformations | No | No | Yes — .transform() |
| Language support | JS/TS only | Every language | JS/TS only |
| Best for | Compile-time contracts | Cross-language APIs | TS projects, form/API validation |