Zod Advanced Patterns (80-20)#
Advanced patterns and real-world use cases.
Recursive Types#
interface Category {
name: string
subcategories: Category[]
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
)
// Usage
const data = {
name: 'Electronics',
subcategories: [
{
name: 'Phones',
subcategories: [
{ name: 'iPhone', subcategories: [] },
{ name: 'Android', subcategories: [] },
],
},
],
}
const result = CategorySchema.parse(data)
Custom Error Messages#
const PasswordSchema = z
.string()
.min(8, { message: 'Password must be at least 8 characters' })
.regex(/[A-Z]/, { message: 'Must contain uppercase letter' })
.regex(/[0-9]/, { message: 'Must contain number' })
.regex(/[^A-Za-z0-9]/, { message: 'Must contain special character' })
Form Validation with Zod#
import { z } from 'zod'
const SignupSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password too short'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
terms: z.boolean().refine((val) => val === true, {
message: 'You must accept terms and conditions',
}),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
type SignupForm = z.infer<typeof SignupSchema>
// React Hook Form integration
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
resolver: zodResolver(SignupSchema),
})
const onSubmit = (data: SignupForm) => {
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign Up</button>
</form>
)
}
API Response Validation#
const UserResponseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
})
const UsersListSchema = z.object({
data: z.array(UserResponseSchema),
total: z.number(),
page: z.number(),
})
// Fetch and validate
async function getUsers(page: number) {
const response = await fetch(`/api/users?page=${page}`)
const json = await response.json()
// Validate response
const result = UsersListSchema.safeParse(json)
if (!result.success) {
console.error('Invalid API response:', result.error)
throw new Error('Invalid response from server')
}
return result.data
}
Environment Variables#
import { z } from 'zod'
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
PORT: z.string().transform((val) => parseInt(val, 10)),
REDIS_URL: z.string().url().optional(),
})
// Validate on startup
const env = EnvSchema.parse(process.env)
// Type-safe env
export const config = {
nodeEnv: env.NODE_ENV,
databaseUrl: env.DATABASE_URL,
apiKey: env.API_KEY,
port: env.PORT,
redisUrl: env.REDIS_URL,
}
Branded Types#
const UserId = z.string().uuid().brand<'UserId'>()
const Email = z.string().email().brand<'Email'>()
type UserId = z.infer<typeof UserId>
type Email = z.infer<typeof Email>
// Type-safe IDs
function getUser(id: UserId) {
// id is branded, can't accidentally pass wrong string
}
const userId = UserId.parse('550e8400-e29b-41d4-a716-446655440000')
getUser(userId) // ✅
const randomString = '123'
getUser(randomString) // ❌ TypeScript error
Preprocessing Data#
const DateSchema = z.preprocess((arg) => {
if (typeof arg === 'string' || arg instanceof Date) {
return new Date(arg)
}
}, z.date())
const result = DateSchema.parse('2025-11-26') // Returns Date object
// Trim strings
const TrimmedString = z.preprocess(
(val) => typeof val === 'string' ? val.trim() : val,
z.string()
)
Custom Validators#
const isValidCreditCard = (val: string) => {
// Luhn algorithm
return /^\d{16}$/.test(val)
}
const CreditCardSchema = z
.string()
.refine(isValidCreditCard, {
message: 'Invalid credit card number',
})
// Async validation
const isEmailTaken = async (email: string) => {
const response = await fetch(`/api/check-email?email=${email}`)
const { taken } = await response.json()
return !taken
}
const UniqueEmailSchema = z
.string()
.email()
.refine(isEmailTaken, {
message: 'Email already taken',
})
Coercion#
// Auto-convert strings to numbers
const NumberFromString = z.coerce.number()
NumberFromString.parse('123') // Returns 123
// Auto-convert to date
const DateFromString = z.coerce.date()
DateFromString.parse('2025-11-26') // Returns Date
// Query params
const QuerySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
search: z.string().optional(),
})
// ?page=2&limit=20&search=hello
const params = QuerySchema.parse({
page: '2',
limit: '20',
search: 'hello',
})
// { page: 2, limit: 20, search: 'hello' }
Pipe#
const LowercaseEmail = z.string().toLowerCase().email()
const TrimmedNonEmpty = z
.string()
.trim()
.min(1, 'Cannot be empty')
Catch#
// Fallback on error
const NumberWithDefault = z.number().catch(0)
NumberWithDefault.parse(123) // 123
NumberWithDefault.parse('invalid') // 0
Real-World: Next.js API Route#
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
published: z.boolean().default(false),
})
export async function POST(request: NextRequest) {
const body = await request.json()
const result = CreatePostSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: result.error.flatten() },
{ status: 400 }
)
}
const post = await db.createPost(result.data)
return NextResponse.json(post)
}
Real-World: tRPC Integration#
import { z } from 'zod'
import { router, publicProcedure } from './trpc'
export const appRouter = router({
createUser: publicProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().min(18).optional(),
})
)
.mutation(async ({ input }) => {
return await db.user.create(input)
}),
getUser: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } })
}),
})
Best Practices
- Use
.safeParse()for external data (user input, API responses) - Use
.parse()for internal data you control - Define schemas as close to usage as possible
- Reuse schemas with
.pick(),.omit(),.extend()