Zod
Targeting Zod 4.x
Syntax
Primitives
import { z } from 'zod'
z.string() // validates string
z.number() // validates number
z.boolean() // validates boolean
z.date() // validates Date instance
z.undefined() // validates undefined
z.null() // validates null
z.any() // allows anythingObjects
const User = z.object({
name: z.string(),
age: z.number(),
email: z.email(), // v4: top-level format validators
})
type User = z.infer<typeof User>
// { name: string; age: number; email: string }Arrays & Tuples
z.array(z.string()) // string[]
z.string().array() // same thing
z.tuple([
z.string(), // [string, number]
z.number(),
])Parse vs SafeParse
const Name = z.string()
Name.parse('Alice') // 'Alice' — returns value
Name.parse(42) // throws ZodError
const result = Name.safeParse(42)
result.success // false
result.error?.issues // [{ message: '...' }]String Methods
z.string().min(1) // at least 1 char
z.string().max(100) // at most 100 chars
z.string().length(5) // exactly 5 chars
z.string().regex(/^[a-z]+/) // matches pattern
z.string().trim() // trims whitespace
// v4: format validators are top-level now
z.email() // valid email
z.url() // valid URL
z.uuid() // strict RFC 9562
z.guid() // permissive UUID-like
z.ipv4() // (was .ip())
z.ipv6()Number Methods
z.number().int() // safe integers only
z.number().positive() // > 0
z.number().nonnegative() // >= 0
z.number().min(1) // >= 1
z.number().max(100) // <= 100
z.number().multipleOf(5) // divisible by 5
// v4: Infinity rejected by default — no .finite() neededUnions & Literals
// union — one of several types
z.union([z.string(), z.number()])
// literal — exact value
z.literal('admin')
z.literal(42)
// enum shorthand
z.enum(['admin', 'user', 'guest'])Optional & Nullable
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
// default value if missing
z.string().default('anon')
z.number().default(0)Beginner Patterns
Infer Types
const Post = z.object({
title: z.string(),
body: z.string(),
published: z.boolean(),
})
// extract the TS type from the schema
type Post = z.infer<typeof Post>
// { title: string; body: string; published: boolean }Nested Objects
const Address = z.object({
street: z.string(),
city: z.string(),
})
const User = z.object({
name: z.string(),
address: Address, // nest schemas
})Default Values
const Config = z.object({
theme: z.enum(['light', 'dark']).default('dark'),
lang: z.string().default('en'),
debug: z.boolean().default(false),
})
Config.parse({}) // { theme: 'dark', lang: 'en', debug: false }Coercion
// coerce converts the input before validating
z.coerce.number().parse('42') // 42
z.coerce.boolean().parse('true') // true
z.coerce.date().parse('2026-01-01') // Date object
// useful for form inputs that are always stringsError Messages
// v4: unified `error` param replaces
// required_error / invalid_type_error
z.string({ error: 'Name is required' })
// per-check messages still work
z.string().min(1, { error: 'Cannot be empty' })
z.number().max(100, { error: 'Too high' })
// or pass a function for dynamic messages
z.string({ error: (issue) => `Got ${issue.input}` })Enums
const Role = z.enum(['admin', 'user', 'guest'])
type Role = z.infer<typeof Role>
// 'admin' | 'user' | 'guest'
Role.parse('admin') // 'admin'
Role.parse('boss') // throws ZodError
Role.enum // v4: use .enum (not .options / .Values / .Enum)
// { admin: 'admin', user: 'user', guest: 'guest' }Intermediate Patterns
Extend
const Base = z.object({ id: z.number() })
// extend — add fields to an existing schema
const User = Base.extend({ name: z.string() })
// { id: number; name: string }
const Admin = User.extend({ role: z.literal('admin') })
// { id: number; name: string; role: 'admin' }
// v4: .merge() deprecated — use .extend() instead
// pass another schema's shape via .extend(Other.shape)Pick & Omit
const User = z.object({
id: z.number(),
name: z.string(),
email: z.email(),
password: z.string(),
})
const Public = User.pick({ id: true, name: true })
const Safe = User.omit({ password: true })Partial & Required
const User = z.object({
name: z.string(),
age: z.number(),
})
const Patch = User.partial()
// { name?: string; age?: number }
const Strict = User.partial().required()
// back to all requiredTransform
const Lower = z.string().transform(s => s.toLowerCase())
Lower.parse('HELLO') // 'hello'
const ToDate = z.string().transform(s => new Date(s))
ToDate.parse('2026-01-01') // Date object
// input type: string → output type: Date
type Input = z.input<typeof ToDate> // string
type Output = z.output<typeof ToDate> // DateRefine
// custom validation logic
const Password = z.string().refine(
(s) => s.length >= 8 && /[A-Z]/.test(s),
{ error: 'Min 8 chars with one uppercase' }
)
// async refine — e.g. check if email is taken
const Email = z.email().refine(
async (email) => !(await isEmailTaken(email)),
{ error: 'Email already in use' }
)Discriminated Union
const Shape = z.discriminatedUnion('type', [
z.object({ type: z.literal('circle'), radius: z.number() }),
z.object({ type: z.literal('rect'), w: z.number(), h: z.number() }),
])
Shape.parse({ type: 'circle', radius: 5 }) // ok
Shape.parse({ type: 'rect', w: 10, h: 20 }) // ok
// better errors than z.union — tells you which variant failedAdvanced Patterns
Recursive Schema
type Category = {
name: string
children: Category[]
}
const Category: z.ZodType<Category> = z.object({
name: z.string(),
children: z.lazy(() => Category.array()),
})Pipe (replaces preprocess)
// v4: z.preprocess() deprecated — use .pipe() instead
const TrimmedString = z
.string()
.transform((s) => s.trim())
.pipe(z.string().min(1))
TrimmedString.parse(' hello ') // 'hello'
TrimmedString.parse(' ') // throws — empty after trimBranded Types
const UserId = z.uuid().brand<'UserId'>()
const PostId = z.uuid().brand<'PostId'>()
type UserId = z.infer<typeof UserId>
type PostId = z.infer<typeof PostId>
// TS prevents mixing them up even though both are strings
function getUser(id: UserId) { /* ... */ }Pipe
// chain schemas — output of first feeds into second
const StringToNumber = z
.string()
.transform(Number)
.pipe(z.number().min(0))
StringToNumber.parse('42') // 42
StringToNumber.parse('-1') // throws — min(0)
StringToNumber.parse('abc') // throws — NaN isn't >= 0Form Validation
const ContactForm = z.object({
name: z.string().min(1, { error: 'Required' }),
email: z.email({ error: 'Invalid email' }),
message: z.string().min(10, { error: 'Too short' }),
})
const result = ContactForm.safeParse(formData)
if (!result.success) {
// v4: .flatten() deprecated — use z.treeifyError()
const tree = z.treeifyError(result.error)
// tree.children.name.errors → ['Required']
}API Response
const ApiResponse = z.object({
data: z.array(z.object({
id: z.number(),
title: z.string(),
})),
meta: z.object({
page: z.number(),
total: z.number(),
}),
})
const parsed = ApiResponse.parse(await res.json())
// fully typed — parsed.data[0].title is stringLast updated on