Zod: TypeScript-First Schema Validation for Any Runtime
TypeScript gives you static type checking at compile time. Zod solves the complementary problem: validating data at runtime that comes from outside TypeScript's control — HTTP request bodies, API responses, environment variables, user input, and config files.
The key insight is that Zod schemas are the source of truth for both runtime validation and TypeScript types. You write the schema once and get both.
The Core Problem Zod Solves
Without runtime validation:
// TypeScript trusts this at compile time
const body = await req.json() as { name: string; age: number }
// But req.json() returns `any` — the cast is a lie
// At runtime, name could be undefined, age could be a string
body.name.toUpperCase() // may throw at runtime
With Zod:
const UserSchema = z.object({
name: z.string(),
age: z.number()
})
const result = UserSchema.safeParse(await req.json())
if (!result.success) {
return new Response('Invalid input', { status: 400 })
}
// result.data is correctly typed as { name: string; age: number }
result.data.name.toUpperCase() // safe
Installation
npm install zod
# or
bun add zod
No peer dependencies. Works in Node.js, Deno, Bun, Cloudflare Workers, and browsers.
Basic Types
import { z } from 'zod'
// Primitives
z.string()
z.number()
z.boolean()
z.bigint()
z.date()
z.undefined()
z.null()
z.any()
z.unknown()
z.never()
// Literals
z.literal('hello')
z.literal(42)
z.literal(true)
// Type inference
const StringSchema = z.string()
type StringType = z.infer<typeof StringSchema> // string
Objects and Arrays
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', 'user', 'moderator']),
createdAt: z.date()
})
type User = z.infer<typeof UserSchema>
// {
// id: string;
// name: string;
// email: string;
// age?: number | undefined;
// role: "admin" | "user" | "moderator";
// createdAt: Date;
// }
const TagsSchema = z.array(z.string()).min(1).max(10)
type Tags = z.infer<typeof TagsSchema> // string[]
parse vs. safeParse
Two ways to validate:
// parse: throws ZodError on failure
try {
const user = UserSchema.parse(rawData)
// user is typed as User
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.issues) // array of validation issues
}
}
// safeParse: returns discriminated union, never throws
const result = UserSchema.safeParse(rawData)
if (result.success) {
const user = result.data // typed as User
} else {
console.log(result.error.issues)
}
safeParse is preferred in most application code — it handles errors without exceptions. parse is useful in startup/init code where a failure is truly fatal and you want to crash immediately.
Schema Composition
// Extending objects
const BaseSchema = z.object({
id: z.string().uuid(),
createdAt: z.date()
})
const PostSchema = BaseSchema.extend({
title: z.string(),
content: z.string()
})
// Picking/omitting fields
const CreatePostSchema = PostSchema.omit({ id: true, createdAt: true })
const PostPreviewSchema = PostSchema.pick({ id: true, title: true })
// Making all fields optional
const UpdatePostSchema = PostSchema.partial()
// Making all fields required
const RequiredSchema = PostSchema.required()
Unions and Discriminated Unions
// Union (any of these types)
const StringOrNumber = z.union([z.string(), z.number()])
// Discriminated union (better performance and error messages)
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() }),
z.object({ type: z.literal('submit'), formId: z.string() })
])
type Event = z.infer<typeof EventSchema>
Discriminated unions give better error messages — when parsing fails, Zod knows which variant to show the error for based on the discriminator field.
Transforms and Preprocessing
// Transform: validate then convert
const NumberStringSchema = z.string().transform((val) => parseInt(val, 10))
// input: string, output: number
// Preprocess: convert before validating
const FlexibleNumberSchema = z.preprocess(
(val) => (typeof val === 'string' ? parseFloat(val) : val),
z.number()
)
// accepts both "42" and 42
// Chaining transforms
const TrimmedEmailSchema = z.string()
.transform((s) => s.trim().toLowerCase())
.pipe(z.string().email())
Validation Refinements
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.refine((val) => /[A-Z]/.test(val), 'Must contain uppercase letter')
.refine((val) => /[0-9]/.test(val), 'Must contain a number')
// Cross-field validation
const SignupSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
)
Practical Patterns
API request validation (Hono/Express)
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user')
})
app.post('/users', async (c) => {
const result = CreateUserSchema.safeParse(await c.req.json())
if (!result.success) {
return c.json({ error: result.error.flatten() }, 400)
}
const user = await createUser(result.data)
return c.json(user, 201)
})
Environment variable validation
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: z.string().min(32)
})
const env = EnvSchema.parse(process.env)
// Throws at startup if env is misconfigured
// env is fully typed: { DATABASE_URL: string; PORT: number; ... }
Form validation with error messages
const ContactFormSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message too short').max(1000)
})
// Get flat error messages per field:
const result = ContactFormSchema.safeParse(formData)
if (!result.success) {
const errors = result.error.flatten().fieldErrors
// errors.email = ['Invalid email address']
// errors.message = ['Message too short']
}
Error Formatting
const result = schema.safeParse(data)
if (!result.success) {
// All issues as an array
result.error.issues
// Flat format: { fieldErrors: {...}, formErrors: [...] }
result.error.flatten()
// Nested format matching input shape
result.error.format()
// Pretty-printed for debugging
console.log(result.error.toString())
}
Zod vs. Alternatives
| Zod | Yup | Joi | io-ts | |
|---|---|---|---|---|
| TypeScript inference | ✓ Excellent | ✓ Good | ✗ Limited | ✓ Good |
| Bundle size | ~15KB | ~20KB | ~25KB | ~10KB |
| Error messages | Clear | Clear | Verbose | Technical |
| Transforms | ✓ | ✓ | ✓ | ✗ |
| Async validation | ✓ | ✓ | ✓ | ✗ |
| Ecosystem | Excellent | Good | Good | Limited |
Zod has become the de facto standard in the TypeScript ecosystem. tRPC, T3 Stack, Hono, Next.js form examples, and most TypeScript API frameworks recommend Zod. When in doubt, start with Zod.
Subscribe to DevTools Guide Newsletter for more developer tooling guides.