Zod - Complete guide to TypeScript-first validation
What is Zod and why did it revolutionize validation?
Zod is a data validation library built specifically for TypeScript. Unlike traditional validation libraries (like Joi or Yup), Zod offers full type inference - you define a schema once, and TypeScript automatically derives the corresponding type.
This means the end of duplication: you no longer need to write a TypeScript interface separately from a validation schema. Zod does both at once.
Why Zod?
TypeScript-first
Code
TypeScript
import { z } from 'zod'
// You define the schema ONCE
const UserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional(),
})
// TypeScript automatically derives the type
type User = z.infer<typeof UserSchema>
// {
// name: string
// email: string
// age?: number | undefined
// }
// No need to write this separately!Zero dependencies
Zod has no runtime dependencies. The package weighs ~50kb (minified), ~12kb (gzipped).
Works everywhere
- Node.js
- Browsers
- React Native
- Edge runtimes (Vercel, Cloudflare)
Installation
Code
Bash
npm install zod
# Or with yarn/pnpm
yarn add zod
pnpm add zodBasic schemas
Primitive types
Code
TypeScript
import { z } from 'zod'
// String
const stringSchema = z.string()
// Number
const numberSchema = z.number()
// Boolean
const booleanSchema = z.boolean()
// Date
const dateSchema = z.date()
// BigInt
const bigintSchema = z.bigint()
// Symbol
const symbolSchema = z.symbol()
// Undefined
const undefinedSchema = z.undefined()
// Null
const nullSchema = z.null()
// Void (undefined or null)
const voidSchema = z.void()
// Any (avoid if possible)
const anySchema = z.any()
// Unknown (safer than any)
const unknownSchema = z.unknown()
// Never
const neverSchema = z.never()String validations
Code
TypeScript
const stringSchema = z.string()
.min(1, 'Required')
.max(100, 'Maximum 100 characters')
.email('Invalid email')
.url('Invalid URL')
.uuid('Invalid UUID')
.cuid()
.cuid2()
.ulid()
.regex(/^[a-z]+$/, 'Lowercase letters only')
.includes('hello')
.startsWith('https://')
.endsWith('.com')
.datetime() // ISO 8601
.ip() // IPv4 or IPv6
.trim() // Trims whitespace before validation
.toLowerCase()
.toUpperCase()
// Usage examples
const EmailSchema = z.string().email()
const PasswordSchema = z.string()
.min(8, 'Minimum 8 characters')
.regex(/[A-Z]/, 'Requires an uppercase letter')
.regex(/[0-9]/, 'Requires a digit')
.regex(/[^A-Za-z0-9]/, 'Requires a special character')Number validations
Code
TypeScript
const numberSchema = z.number()
.gt(5) // > 5
.gte(5) // >= 5
.lt(10) // < 10
.lte(10) // <= 10
.int() // integers only
.positive() // > 0
.nonnegative() // >= 0
.negative() // < 0
.nonpositive() // <= 0
.multipleOf(5) // 5, 10, 15...
.finite() // not Infinity
.safe() // Number.MIN_SAFE_INTEGER - MAX_SAFE_INTEGER
// Examples
const AgeSchema = z.number().int().min(0).max(150)
const PriceSchema = z.number().positive().multipleOf(0.01)Objects
Code
TypeScript
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
metadata: z.record(z.string()), // { [key: string]: string }
})
type User = z.infer<typeof UserSchema>
// Partial - all fields optional
const PartialUserSchema = UserSchema.partial()
// Required - all fields required
const RequiredUserSchema = UserSchema.required()
// Pick - select only certain fields
const UserNameSchema = UserSchema.pick({ name: true, email: true })
// Omit - skip certain fields
const UserWithoutIdSchema = UserSchema.omit({ id: true })
// Extend - extend the schema
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
department: z.string(),
})
// Merge - combine two schemas
const CombinedSchema = UserSchema.merge(z.object({
address: z.string(),
}))
// passthrough - allow additional fields
const FlexibleSchema = UserSchema.passthrough()
// strict - error on additional fields
const StrictSchema = UserSchema.strict()
// strip - remove additional fields (default)
const StrippedSchema = UserSchema.strip()Arrays
Code
TypeScript
const StringArraySchema = z.array(z.string())
const NumberArraySchema = z.array(z.number())
.min(1, 'Minimum 1 element')
.max(10, 'Maximum 10 elements')
.length(5, 'Exactly 5 elements')
.nonempty('Array cannot be empty')
// Tuple - array with fixed length and types
const CoordinatesSchema = z.tuple([z.number(), z.number()])
type Coordinates = z.infer<typeof CoordinatesSchema> // [number, number]
// Tuple with rest
const ArgsSchema = z.tuple([z.string(), z.number()]).rest(z.boolean())
// [string, number, ...boolean[]]Unions and literals
Code
TypeScript
// Literal
const StatusSchema = z.literal('active')
type Status = z.infer<typeof StatusSchema> // 'active'
// Enum (z.enum)
const RoleSchema = z.enum(['admin', 'user', 'guest'])
type Role = z.infer<typeof RoleSchema> // 'admin' | 'user' | 'guest'
// Accessing enum values
RoleSchema.options // ['admin', 'user', 'guest']
RoleSchema.enum // { admin: 'admin', user: 'user', guest: 'guest' }
// Native enum
enum NativeRole {
Admin = 'admin',
User = 'user',
}
const NativeRoleSchema = z.nativeEnum(NativeRole)
// Union
const StringOrNumberSchema = z.union([z.string(), z.number()])
// or shorter:
const StringOrNumber = z.string().or(z.number())
// Discriminated union (better performance)
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('scroll'), direction: z.enum(['up', 'down']) }),
])
type Event = z.infer<typeof EventSchema>
// | { type: 'click', x: number, y: number }
// | { type: 'keypress', key: string }
// | { type: 'scroll', direction: 'up' | 'down' }Nullable and Optional
Code
TypeScript
// Optional - can be undefined
const OptionalSchema = z.string().optional()
type Optional = z.infer<typeof OptionalSchema> // string | undefined
// Nullable - can be null
const NullableSchema = z.string().nullable()
type Nullable = z.infer<typeof NullableSchema> // string | null
// Nullish - can be null or undefined
const NullishSchema = z.string().nullish()
type Nullish = z.infer<typeof NullishSchema> // string | null | undefined
// Default - default value if undefined
const DefaultSchema = z.string().default('default value')
const WithDefault = z.object({
name: z.string(),
role: z.string().default('user'),
})
// Catch - fallback value if validation fails
const CatchSchema = z.string().catch('fallback')Validation and parsing
safeParse vs parse
Code
TypeScript
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
})
// parse - throws an error if invalid
try {
const user = UserSchema.parse({
name: 'John',
email: 'invalid-email',
})
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues)
}
}
// safeParse - returns a result object (recommended!)
const result = UserSchema.safeParse({
name: 'John',
email: 'john@example.com',
})
if (result.success) {
console.log(result.data) // typed!
// { name: string, email: string }
} else {
console.log(result.error.issues)
// [{ code: 'invalid_string', message: 'Invalid email', path: ['email'] }]
}
// parseAsync - for schemas with async refinements
const asyncResult = await UserSchema.safeParseAsync(data)Error handling
Code
TypeScript
const result = UserSchema.safeParse(invalidData)
if (!result.success) {
// Raw issues
console.log(result.error.issues)
// Formatted errors
const formatted = result.error.format()
// {
// name: { _errors: ['Required'] },
// email: { _errors: ['Invalid email'] }
// }
// Flattened errors (great for forms)
const flattened = result.error.flatten()
// {
// formErrors: [],
// fieldErrors: {
// name: ['Required'],
// email: ['Invalid email']
// }
// }
}Transformations
Code
TypeScript
// Transform - transform the value after validation
const StringToNumberSchema = z.string().transform((val) => parseInt(val, 10))
type StringToNumber = z.infer<typeof StringToNumberSchema> // number
// Transform with validation
const PositiveIntSchema = z.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().positive().int())
// Coerce - automatic type conversion
const CoercedStringSchema = z.coerce.string() // any -> string
const CoercedNumberSchema = z.coerce.number() // "42" -> 42
const CoercedBooleanSchema = z.coerce.boolean() // "true" -> true
const CoercedDateSchema = z.coerce.date() // "2024-01-01" -> Date
// Preprocess - transformation BEFORE validation
const TrimmedStringSchema = z.preprocess(
(val) => (typeof val === 'string' ? val.trim() : val),
z.string().min(1)
)
// Practical example: API input
const ApiInputSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
search: z.string().trim().optional(),
sortBy: z.enum(['name', 'date', 'price']).default('date'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
})Refinements
Code
TypeScript
// Refine - custom validation
const PasswordSchema = z.string()
.min(8)
.refine((val) => /[A-Z]/.test(val), {
message: 'Requires an uppercase letter',
})
.refine((val) => /[0-9]/.test(val), {
message: 'Requires a digit',
})
// Superrefine - full control over errors
const FormSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords must be identical',
path: ['confirmPassword'],
})
}
})
// Async refine (e.g., checking if email exists in the database)
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email)
return !exists
},
{ message: 'Email already exists' }
)Integration with React Hook Form
Code
Bash
npm install react-hook-form @hookform/resolvers zodCode
TypeScript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Validation schema
const RegisterSchema = z.object({
name: z.string().min(2, 'Minimum 2 characters'),
email: z.string().email('Invalid email'),
password: z.string()
.min(8, 'Minimum 8 characters')
.regex(/[A-Z]/, 'Requires an uppercase letter')
.regex(/[0-9]/, 'Requires a digit'),
confirmPassword: z.string(),
terms: z.boolean().refine((val) => val === true, {
message: 'You must accept the terms of service',
}),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords must be identical',
path: ['confirmPassword'],
})
type RegisterForm = z.infer<typeof RegisterSchema>
function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterForm>({
resolver: zodResolver(RegisterSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
terms: false,
},
})
const onSubmit = async (data: RegisterForm) => {
console.log('Valid data:', data)
await registerUser(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register('name')} />
{errors.name && <span className="error">{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm password</label>
<input id="confirmPassword" type="password" {...register('confirmPassword')} />
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
<div>
<label>
<input type="checkbox" {...register('terms')} />
I accept the terms of service
</label>
{errors.terms && <span className="error">{errors.terms.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
)
}Server Actions (Next.js)
TSapp/actions/posts.ts
TypeScript
// app/actions/posts.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).min(1, 'Add at least one tag').max(5),
published: z.boolean().default(false),
})
export type CreatePostInput = z.infer<typeof CreatePostSchema>
export type ActionResult = {
success: boolean
errors?: Record<string, string[]>
data?: any
}
export async function createPost(formData: FormData): Promise<ActionResult> {
// Extract data from FormData
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
published: formData.get('published') === 'true',
}
// Validate
const result = CreatePostSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// Save to database
try {
const post = await db.posts.create({
data: result.data,
})
revalidatePath('/posts')
return {
success: true,
data: post,
}
} catch (error) {
return {
success: false,
errors: { _form: ['Error saving to database'] },
}
}
}TSapp/posts/new/page.tsx
TypeScript
// app/posts/new/page.tsx
'use client'
import { useFormState } from 'react-dom'
import { createPost, type ActionResult } from '@/app/actions/posts'
const initialState: ActionResult = { success: false }
export default function NewPostPage() {
const [state, formAction] = useFormState(createPost, initialState)
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
{state.errors?.title && (
<span className="error">{state.errors.title[0]}</span>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
{state.errors?.content && (
<span className="error">{state.errors.content[0]}</span>
)}
</div>
<button type="submit">Publish</button>
{state.success && <p className="success">Post has been created!</p>}
</form>
)
}API Validation
TSapp/api/users/route.ts
TypeScript
// app/api/users/route.ts
import { z } from 'zod'
import { NextResponse } from 'next/server'
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user'),
})
export async function POST(request: Request) {
try {
const body = await request.json()
const result = CreateUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const user = await db.users.create({
data: result.data,
})
return NextResponse.json(user, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
// Query params validation
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
search: z.string().optional(),
})
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = QuerySchema.safeParse({
page: searchParams.get('page'),
limit: searchParams.get('limit'),
search: searchParams.get('search'),
})
if (!query.success) {
return NextResponse.json(
{ error: 'Invalid query parameters' },
{ status: 400 }
)
}
const { page, limit, search } = query.data
const users = await db.users.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json({ users, page, limit })
}Zod vs Alternatives
| Aspect | Zod | Yup | Joi |
|---|---|---|---|
| TypeScript | First-class | Good | Poor |
| Type inference | Automatic | Requires type | No |
| Bundle size | ~12kb | ~15kb | ~30kb |
| Performance | Very good | Good | Average |
| API | Functional | Chainable | Chainable |
| Async validation | Yes | Yes | Yes |
Best practices
Do's
Code
TypeScript
// Use safeParse instead of parse
const result = schema.safeParse(data)
// Define schemas outside of components
const UserSchema = z.object({ ... })
// Use coerce for data from forms/URLs
z.coerce.number()
// Extract types from schemas
type User = z.infer<typeof UserSchema>
// Use custom error messages
z.string().min(1, 'Field is required')Don'ts
Code
TypeScript
// Don't define schemas inside render
function Component() {
const Schema = z.object({}) // Creates on every render
}
// Don't use parse without try-catch
const data = Schema.parse(input) // Can throw!
// Don't ignore the infer type
const Schema = z.object({ name: z.string() })
interface User { name: string } // DuplicationFAQ
Does Zod work with JavaScript (without TypeScript)?
Yes, but you lose the main advantage - automatic type inference.
How to validate files?
Code
TypeScript
const FileSchema = z.instanceof(File).refine(
(file) => file.size <= 5 * 1024 * 1024,
'Maximum size: 5MB'
)How to do conditional validation?
Code
TypeScript
const Schema = z.object({
type: z.enum(['email', 'phone']),
value: z.string(),
}).refine(
(data) => {
if (data.type === 'email') {
return z.string().email().safeParse(data.value).success
}
return z.string().regex(/^\d{9}$/).safeParse(data.value).success
},
{ message: 'Invalid format', path: ['value'] }
)Summary
Zod is a must-have for TypeScript projects:
- Type inference - One schema, automatic type
- Zero dependencies - Small bundle
- Great API - Clear and functional
- Integrations - React Hook Form, tRPC, Next.js