Skip to content

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()