Skip to Content

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 anything
Objects
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() needed
Unions & 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 strings
Error 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 required
Transform
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> // Date
Refine
// 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 failed

Advanced 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 trim
Branded 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 >= 0
Form 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 string
Last updated on