Zod JSON Validation: Schemas, Parsing, Type Inference & Error Formatting

Last updated:

Zod validates JSON at runtime and infers TypeScript types statically — a single z.object({...}) schema replaces both a TypeScript interface and a hand-written validation function, halving the code needed to safely consume external JSON. z.parse() throws on invalid JSON structure, returning a typed value on success; z.safeParse() returns { success: boolean, data?, error? } with no throw, making it 3× safer for API boundary validation where you control the error response. This guide covers defining Zod schemas for JSON objects and arrays, TypeScript type inference with z.infer, custom refinements and transforms, formatting ZodError for API clients, and integrating Zod in tRPC, React Hook Form, and Next.js Server Actions.

Defining JSON Schemas with z.object and z.array

Zod schema primitives map directly to JSON types: z.string(), z.number(), z.boolean(), z.null(), and z.undefined() cover JSON scalar values. z.object({}) validates JSON objects with named fields; z.array(schema) validates JSON arrays of a uniform type. Nest schemas to mirror any JSON structure. Mark fields optional with .optional() (allows undefined) or nullable with .nullable() (allows null). Use z.record(z.string(), valueSchema) for dynamic JSON objects where keys are not known at compile time.

import { z } from 'zod'

// ── Primitive schemas ──────────────────────────────────────────
const StringSchema  = z.string()
const NumberSchema  = z.number()
const BooleanSchema = z.boolean()

// ── z.object — validates a JSON object with named fields ───────
const UserSchema = z.object({
  id:        z.string().uuid(),          // must be a valid UUID
  name:      z.string().min(1).max(100), // 1-100 chars
  email:     z.string().email(),         // valid email format
  age:       z.number().int().min(0).max(150).optional(), // optional field
  bio:       z.string().nullable(),      // string or null
  createdAt: z.string().datetime(),      // ISO 8601 datetime string
})

// Inferred TypeScript type — no separate interface needed
type User = z.infer<typeof UserSchema>
// {
//   id: string
//   name: string
//   email: string
//   age?: number | undefined
//   bio: string | null
//   createdAt: string
// }

// ── z.array — validates a JSON array ──────────────────────────
const UsersSchema = z.array(UserSchema)
// also: z.object({ users: z.array(UserSchema) })

// ── Nested objects ─────────────────────────────────────────────
const OrderSchema = z.object({
  id:    z.string(),
  total: z.number().positive(),
  items: z.array(z.object({
    productId: z.string(),
    quantity:  z.number().int().min(1),
    price:     z.number().positive(),
  })),
  user: UserSchema, // nested schema reuse
})

// ── z.record — dynamic JSON keys ──────────────────────────────
// Validates { [key: string]: number } — e.g. { clicks: 5, views: 100 }
const StatsSchema = z.record(z.string(), z.number())

// ── Required vs optional in practice ──────────────────────────
const UpdateUserSchema = UserSchema
  .partial()           // make ALL fields optional (for PATCH requests)
  .required({ id: true }) // but keep id required

// ── Parsing ───────────────────────────────────────────────────
const json = { id: '550e8400-e29b-41d4-a716-446655440000', name: 'Alice', email: 'alice@example.com', bio: null, createdAt: '2026-05-19T10:00:00Z' }

const user: User = UserSchema.parse(json)   // throws ZodError if invalid
const result = UserSchema.safeParse(json)   // never throws
if (result.success) {
  console.log(result.data.email)            // typed as string
} else {
  console.error(result.error.issues)        // ZodIssue[]
}

Chain validation methods on primitives to add constraints: z.string().min(1) rejects empty strings, z.number().int() rejects floats, z.string().url() validates URL format. Zod runs constraints left-to-right and short-circuits on the first failure unless you use z.object().superRefine() for multi-field cross-validation. All Zod schemas are immutable — methods like .optional() return new schema instances rather than mutating the original, making schema composition safe.

parse vs safeParse: Choosing the Right Boundary

The choice between parse() and safeParse() depends on whether a validation failure is a programming error or an expected runtime condition. Use parse() for internal invariants — environment variables at startup, configuration files, compile-time-known shapes — where failure should crash the process immediately. Use safeParse() at every external boundary — HTTP request bodies, API responses, user form input — where you must handle the error gracefully and return a structured response to the caller.

import { z } from 'zod'

// ── z.parse() — throws ZodError on failure ─────────────────────
// Good for: startup validation, internal invariants
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT:         z.coerce.number().int().min(1024).max(65535),
  NODE_ENV:     z.enum(['development', 'production', 'test']),
})

// Throws immediately if env is misconfigured — fail fast at startup
const env = EnvSchema.parse(process.env)
// env.DATABASE_URL is typed as string — no need for ! assertions

// ── z.safeParse() — never throws, returns Result type ──────────
// Good for: API handlers, form validation, external data
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

const CreateUserSchema = z.object({
  name:  z.string().min(1).max(100),
  email: z.string().email(),
  age:   z.coerce.number().int().min(18),
})

export async function POST(req: NextRequest) {
  const body = await req.json()
  const result = CreateUserSchema.safeParse(body)

  if (!result.success) {
    // Return 422 with structured error details — no throw, full control
    return NextResponse.json(
      { success: false, errors: result.error.flatten().fieldErrors },
      { status: 422 }
    )
  }

  // result.data is fully typed as { name: string; email: string; age: number }
  const user = await db.createUser(result.data)
  return NextResponse.json({ success: true, user }, { status: 201 })
}

// ── Typed Result utility — wraps safeParse for reuse ──────────
type Result<T> =
  | { success: true;  data: T }
  | { success: false; error: z.ZodError }

function safeValidate<T>(schema: z.ZodSchema<T>, input: unknown): Result<T> {
  const result = schema.safeParse(input)
  if (result.success) return { success: true,  data: result.data }
  return               { success: false, error: result.error }
}

// ── safeParse return type shape ────────────────────────────────
// result.success === true  → { success: true,  data: T }
// result.success === false → { success: false, error: ZodError }
// ZodError.issues: ZodIssue[] — each issue has:
//   code:    string  (e.g. 'too_small', 'invalid_type', 'invalid_string')
//   path:    (string | number)[]  — path to the failing field
//   message: string  — human-readable description

safeParse() accesses result.error.issues for the raw array of ZodIssue objects, each containing a code (machine-readable), path (field path array), and message (human-readable). The code values are constants from z.ZodIssueCode — use them for programmatic error routing rather than parsing message strings, which can change between Zod minor versions. For async validation (database uniqueness checks, remote API calls), use schema.parseAsync() and schema.safeParseAsync(), which return Promises.

TypeScript Type Inference with z.infer

z.infer<typeof Schema> extracts the output TypeScript type from a Zod schema at compile time — zero runtime cost, no code generation step, no separate .d.ts files. This single line replaces maintaining parallel interface declarations alongside validation functions. When the schema changes, the inferred type changes automatically, making desynchronization between types and validators impossible.

import { z } from 'zod'

// ── Basic inference ────────────────────────────────────────────
const ProductSchema = z.object({
  id:       z.string().uuid(),
  name:     z.string(),
  price:    z.number().positive(),
  tags:     z.array(z.string()),
  metadata: z.record(z.string(), z.unknown()).optional(),
})

// z.infer extracts the OUTPUT type (after transforms)
type Product = z.infer<typeof ProductSchema>
// {
//   id: string
//   name: string
//   price: number
//   tags: string[]
//   metadata?: Record<string, unknown>
// }

// ── Input vs Output types (when transforms are involved) ───────
const DateStringSchema = z.string().datetime().transform(s => new Date(s))
//   Input type:  string   (what .parse() accepts)
//   Output type: Date     (what .parse() returns)

type DateInput  = z.input<typeof DateStringSchema>   // string
type DateOutput = z.output<typeof DateStringSchema>  // Date
// z.infer === z.output — always the post-transform type

// ── Using inferred types in function signatures ─────────────────
// Share schema between frontend (validation) and backend (handler)
// In a shared /types/api.ts file:
export const ApiUserSchema = z.object({
  id:    z.string(),
  name:  z.string(),
  email: z.string().email(),
})
export type ApiUser = z.infer<typeof ApiUserSchema>

// Frontend — validates response
async function fetchUser(id: string): Promise<ApiUser> {
  const res  = await fetch(`/api/users/${id}`)
  const json = await res.json()
  return ApiUserSchema.parse(json) // throws if API changed shape
}

// Backend — uses same type in handler
async function createUser(data: ApiUser): Promise<void> {
  await db.insert('users', data) // data fully typed as ApiUser
}

// ── Extracting partial schemas ──────────────────────────────────
// Get the schema for a single field
const EmailSchema = ProductSchema.shape.price // z.ZodNumber
type Price        = z.infer<typeof EmailSchema> // number

// Omit / Pick on schemas — mirrors TypeScript utility types
const PublicProductSchema = ProductSchema.omit({ metadata: true })
const PricedProductSchema = ProductSchema.pick({ id: true, price: true })

type PublicProduct = z.infer<typeof PublicProductSchema>
type PricedProduct = z.infer<typeof PricedProductSchema>

A practical pattern for full-stack TypeScript monorepos: define all API schemas in a shared package, export both the schema and the inferred type, and import them in both the frontend (for validation) and the backend (for handler types). This creates a single source of truth — the schema — that drives runtime validation, TypeScript types, and OpenAPI documentation generation (via libraries like zod-to-json-schema). The schema is the contract; TypeScript types and docs are derived artifacts.

Custom Refinements and Transforms

Refinements add custom validation logic beyond Zod's built-in constraints; transforms reshape data as it passes through the schema. Both are applied after structural validation succeeds. Use .refine() for single custom validation rules, .superRefine() for multiple issues or cross-field rules, and .transform() to convert JSON input into a different output type. Chain them with .pipe()to pass one schema's output as the next schema's input.

import { z } from 'zod'

// ── .refine() — single custom rule ────────────────────────────
const PasswordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .refine(s => /[A-Z]/.test(s), 'Must contain at least one uppercase letter')
  .refine(s => /[0-9]/.test(s), 'Must contain at least one number')

// ── .superRefine() — cross-field validation ────────────────────
const SignupSchema = z.object({
  email:           z.string().email(),
  password:        z.string().min(8),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code:    z.ZodIssueCode.custom,
      path:    ['confirmPassword'],
      message: 'Passwords do not match',
    })
  }
  if (data.email.includes(data.password)) {
    ctx.addIssue({
      code:    z.ZodIssueCode.custom,
      path:    ['password'],
      message: 'Password must not contain your email address',
    })
  }
})

// ── .transform() — reshape JSON data ──────────────────────────
// Convert snake_case API response to camelCase domain object
const ApiUserSchema = z.object({
  user_id:    z.string(),
  first_name: z.string(),
  last_name:  z.string(),
  created_at: z.string().datetime(),
}).transform(data => ({
  userId:    data.user_id,
  firstName: data.first_name,
  lastName:  data.last_name,
  createdAt: new Date(data.created_at), // string → Date
}))

type DomainUser = z.infer<typeof ApiUserSchema>
// { userId: string; firstName: string; lastName: string; createdAt: Date }

// ── .pipe() — chain schemas ────────────────────────────────────
// Parse a string, then validate the parsed number
const StringToPositiveInt = z
  .string()
  .transform(s => parseInt(s, 10))
  .pipe(z.number().int().positive())

// ── z.preprocess() — run code before Zod validation ───────────
// Useful for normalizing input before schema validation
const TrimmedStringSchema = z.preprocess(
  val => (typeof val === 'string' ? val.trim() : val),
  z.string().min(1)
)

// ── .refine() with async — for DB uniqueness checks ───────────
const UniqueEmailSchema = z.string().email().refine(
  async email => {
    const existing = await db.users.findOne({ email })
    return !existing
  },
  { message: 'Email is already registered' }
)
// Use with .safeParseAsync() — .parse() throws for async schemas

.superRefine() is preferred over chaining multiple .refine() calls when you need to add multiple issues or when one issue should prevent checking another. With chained .refine(), Zod aborts after the first failure; with .superRefine(), you can collect all issues in one pass. Transforms run only when validation passes — if the input fails structural validation or refinements, the transform never executes, preventing runtime errors inside transform functions.

ZodError Formatting for API Responses

ZodError provides structured formatting methods that translate validation failures into client-friendly JSON. error.format() returns a nested object mirroring the schema structure. error.flatten() returns a flat { fieldErrors, formErrors } object ideal for HTTP 422 responses and form displays. Custom error maps allow global error message overrides for internationalization. All formatting is synchronous — no async needed.

import { z } from 'zod'

const Schema = z.object({
  name:  z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email address'),
  age:   z.number().int().min(18, 'Must be 18 or older'),
})

const result = Schema.safeParse({ name: '', email: 'not-an-email', age: 15 })

if (!result.success) {
  const { error } = result

  // ── error.format() — nested object matching schema shape ────
  const formatted = error.format()
  // {
  //   name:  { _errors: ['Name is required'] },
  //   email: { _errors: ['Invalid email address'] },
  //   age:   { _errors: ['Must be 18 or older'] },
  //   _errors: []  // top-level errors (from root-level refinements)
  // }

  // ── error.flatten() — flat field→errors map ─────────────────
  const flat = error.flatten()
  // {
  //   fieldErrors: {
  //     name:  ['Name is required'],
  //     email: ['Invalid email address'],
  //     age:   ['Must be 18 or older'],
  //   },
  //   formErrors: []  // root-level (non-field) errors
  // }

  // ── HTTP 422 JSON response ──────────────────────────────────
  // return Response.json({
  //   success: false,
  //   errors:  error.flatten().fieldErrors,
  // }, { status: 422 })
}

// ── Custom error messages on validators ───────────────────────
const StrictSchema = z.object({
  username: z.string()
    .min(3, { message: 'Username too short — minimum 3 characters' })
    .max(20, { message: 'Username too long — maximum 20 characters' })
    .regex(/^[a-z0-9_]+$/, { message: 'Only lowercase letters, numbers, and underscores' }),
})

// ── z.setErrorMap() — global custom error messages (i18n) ──────
const chineseErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small && issue.type === 'string') {
    return { message: `最少需要 ${issue.minimum} 个字符` }
  }
  if (issue.code === z.ZodIssueCode.invalid_string && issue.validation === 'email') {
    return { message: '请输入有效的电子邮件地址' }
  }
  return { message: ctx.defaultError }
}
z.setErrorMap(chineseErrorMap)

// ── error.issues — raw access to all ZodIssue objects ─────────
// result.error.issues: ZodIssue[]
// Each ZodIssue: { code, path, message, ...codeSpecificFields }
// Useful for logging full details or custom client formatting

For internationalized applications, combine z.setErrorMap() with a locale detection function that sets the error map based on the request's Accept-Language header. Since z.setErrorMap() sets a global override, in server environments with concurrent requests you should pass the error map per-parse via schema.parse(data, { errorMap }) rather than setting the global map — this avoids locale leakage between concurrent requests.

Zod in tRPC, React Hook Form, and Server Actions

Zod integrates as the native validation layer for tRPC procedures, React Hook Form, and Next.js Server Actions. In each context, the same Zod schema drives both runtime validation and TypeScript type inference — define once, validate everywhere. The integration libraries (@trpc/server, @hookform/resolvers/zod) handle calling safeParse() internally and surfacing errors in their respective formats.

import { z } from 'zod'

// ── tRPC .input(schema) ────────────────────────────────────────
import { router, publicProcedure } from './trpc'

const CreatePostInput = z.object({
  title:   z.string().min(1).max(200),
  content: z.string().min(10),
  tags:    z.array(z.string()).max(5),
})

export const postRouter = router({
  create: publicProcedure
    .input(CreatePostInput)           // Zod validates the input
    .mutation(async ({ input, ctx }) => {
      // input is typed as z.infer<typeof CreatePostInput>
      return ctx.db.post.create({ data: input })
    }),

  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input, ctx }) => {
      return ctx.db.post.findUnique({ where: { id: input.id } })
    }),
})

// ── React Hook Form with zodResolver ──────────────────────────
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

const SignupSchema = z.object({
  email:    z.string().email(),
  password: z.string().min(8),
  age:      z.coerce.number().int().min(18),
})
type SignupForm = z.infer<typeof SignupSchema>

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
    resolver: zodResolver(SignupSchema),
  })

  const onSubmit = (data: SignupForm) => {
    // data is fully typed — age is number (z.coerce converted the string input)
    console.log(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="number" {...register('age')} />
      {errors.age && <span>{errors.age.message}</span>}
      <button type="submit">Sign up</button>
    </form>
  )
}

// ── Next.js Server Action ──────────────────────────────────────
'use server'

const ActionSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('create'), title: z.string().min(1) }),
  z.object({ type: z.literal('delete'), id: z.string().uuid() }),
])
type ActionInput = z.infer<typeof ActionSchema>

export async function postAction(formData: FormData) {
  const raw = Object.fromEntries(formData)
  const result = ActionSchema.safeParse(raw)

  if (!result.success) {
    return { success: false, errors: result.error.flatten().fieldErrors }
  }

  // result.data is typed as ActionInput (discriminated union)
  if (result.data.type === 'create') {
    await db.post.create({ data: { title: result.data.title } })
  } else {
    await db.post.delete({ where: { id: result.data.id } })
  }

  return { success: true }
}

In tRPC, the .input(schema) call makes the input parameter of .query() and .mutation() handlers fully typed — no manual type assertions. tRPC's client-side inferring of the router types means that even call-site TypeScript checks against the Zod schema's input type. For Server Actions, z.discriminatedUnion on an action type field is a clean pattern for routing multiple action types through a single Server Action, replacing URL-based routing with type-safe JSON dispatch.

Advanced Patterns: Unions, Intersections, and Lazy Schemas

Zod supports the full range of TypeScript structural type patterns for complex JSON shapes. z.union() validates one of multiple schema alternatives; z.discriminatedUnion() selects the schema branch based on a discriminant field for better performance and error messages. z.intersection() combines two schemas requiring both to pass. z.lazy() defines recursive schemas for tree-shaped JSON. z.preprocess() runs arbitrary transformations before validation begins.

import { z } from 'zod'

// ── z.union — JSON polymorphism ────────────────────────────────
// Tries each schema in order, uses first that passes
const StringOrNumber = z.union([z.string(), z.number()])

// ── z.discriminatedUnion — preferred for tagged unions ─────────
// Faster than z.union: jumps directly to the branch matching the discriminant
const EventSchema = z.discriminatedUnion('type', [
  z.object({
    type:    z.literal('click'),
    x:       z.number(),
    y:       z.number(),
    button:  z.enum(['left', 'right', 'middle']),
  }),
  z.object({
    type:    z.literal('keypress'),
    key:     z.string(),
    code:    z.string(),
    ctrlKey: z.boolean(),
  }),
  z.object({
    type:    z.literal('resize'),
    width:   z.number().positive(),
    height:  z.number().positive(),
  }),
])
type Event = z.infer<typeof EventSchema>

// ── z.intersection — combine two schemas (both must pass) ──────
const TimestampedSchema = z.object({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
})
const TimestampedUser = z.intersection(UserSchema, TimestampedSchema)
// Equivalent: UserSchema.and(TimestampedSchema)

// ── z.lazy() — recursive JSON structures ──────────────────────
// Must annotate the type explicitly to satisfy TypeScript
type TreeNode = {
  value:    string
  children: TreeNode[]
}

const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
  z.object({
    value:    z.string(),
    children: z.array(TreeNodeSchema),
  })
)

// Validates a JSON tree of arbitrary depth
const tree = TreeNodeSchema.parse({
  value: 'root',
  children: [
    { value: 'child1', children: [] },
    { value: 'child2', children: [{ value: 'grandchild', children: [] }] },
  ],
})

// ── z.lazy() with discriminated union — AST node types ────────
type AstNode =
  | { type: 'literal'; value: string | number }
  | { type: 'binary';  left: AstNode; op: string; right: AstNode }

const AstNodeSchema: z.ZodType<AstNode> = z.lazy(() =>
  z.discriminatedUnion('type', [
    z.object({ type: z.literal('literal'), value: z.union([z.string(), z.number()]) }),
    z.object({ type: z.literal('binary'),  left: AstNodeSchema, op: z.string(), right: AstNodeSchema }),
  ])
)

// ── z.preprocess — normalize before validation ─────────────────
// Parse JSON string input before schema validation
const JsonStringSchema = z.preprocess(
  val => {
    if (typeof val !== 'string') return val
    try { return JSON.parse(val) } catch { return val }
  },
  z.object({ id: z.string(), name: z.string() })
)

// Accepts either an object or a JSON string '{"id":"1","name":"Alice"}'
const parsed = JsonStringSchema.parse('{"id":"1","name":"Alice"}')

z.discriminatedUnion() is significantly faster than z.union() for large union types — it reads the discriminant field value and jumps directly to the matching branch rather than trying each schema in sequence. Error messages are also more precise: instead of listing all union alternatives that failed, it reports only why the matching branch failed. For JSON APIs that use a type or kind field to distinguish response shapes (a common REST and GraphQL pattern), always prefer z.discriminatedUnion() over z.union().

Key Terms

schema
A Zod schema is a reusable, composable validator object that describes the expected shape and constraints of a JSON value. Schemas are created by calling Zod factory functions (z.string(), z.object(), z.array()) and chaining methods (.min(), .optional(), .refine()). Every schema implements the ZodType interface, exposing .parse(), .safeParse(), .parseAsync(), and .safeParseAsync() methods. Schemas are immutable — all modifier methods return new schema instances. The TypeScript type produced by a schema is extracted with z.infer<typeof schema>.
refinement
A refinement adds a custom validation function on top of Zod's built-in constraints. Added via .refine(fn, message) or .superRefine(fn), refinements run after structural validation passes. The .refine() callback receives the typed, parsed value and must return a boolean — false triggers a custom validation error. .superRefine() receives both the value and a ctx object; calling ctx.addIssue() adds validation issues without aborting, allowing multiple issues to be collected in a single pass. Async refinements (returning a Promise) require using parseAsync() or safeParseAsync().
transform
A transform is a function applied to the validated value to produce a different output type. Added via .transform(fn), it runs after all validation and refinements pass — if validation fails, the transform never executes. Transforms change a schema's output type: z.string().transform(s => new Date(s)) produces a schema with input type string and output type Date. z.infer returns the output type (post-transform); use z.input to get the input type. Common uses: converting snake_case to camelCase, parsing date strings to Date objects, normalizing enum values, and trimming strings.
ZodError
ZodError is the error class thrown by schema.parse() on validation failure. It extends Error and contains an issues array of ZodIssue objects, one per validation failure. Each ZodIssue has a code (machine-readable string from z.ZodIssueCode), a path array (field path from the root to the failing field), and a message string. ZodError provides .format() for nested formatted output and .flatten() for flat field-to-errors mapping. In safeParse(), ZodError is available as result.error when result.success is false.
discriminated union
A discriminated union is a z.discriminatedUnion(discriminantKey, [schemas]) that selects the correct schema branch based on a literal discriminant field value. Unlike z.union(), which tries each schema in order, z.discriminatedUnion() reads the discriminant field value from the input and directly selects the matching branch — O(1) lookup versus O(n) sequential try. This produces clearer error messages (only the matching branch's errors are reported) and better performance for large unions. The discriminant field must be a z.literal() or z.enum() in each branch. Commonly used for JSON API responses with a type, kind, or status field.
coercion
Coercion in Zod converts an input value to a different type before validation, using the z.coerce namespace. z.coerce.number() calls Number(input) before validating as a number — converting "42" to 42, "3.14" to 3.14, and throwing on "abc". z.coerce.boolean() calls Boolean(input). z.coerce.date() calls new Date(input). Coercion is essential for validating URL query parameters and HTML form data, which are always strings. Unlike .transform(), coercion runs before Zod's type check rather than after, making it suitable for type conversion rather than post-validation transformation.

FAQ

What is the difference between z.parse and z.safeParse in Zod?

z.parse() throws a ZodError exception when validation fails, returning the typed value directly on success. z.safeParse() never throws — it always returns an object with a success boolean field. When success is true, the data field contains the typed value; when success is false, the error field contains a ZodError with full issue details. Use z.parse() for internal invariants where a validation failure is a programming error and you want a hard crash — for example, validating environment variables at startup. Use z.safeParse() at API boundaries — HTTP request handlers, form submissions, external JSON responses — where you want to control the error response yourself. The performance difference is negligible: z.safeParse() adds approximately 0.01 ms overhead for wrapping the try/catch internally.

How do I validate a JSON API response with Zod?

Define a Zod schema matching the expected API response shape, then call schema.safeParse(data) on the parsed JSON. First fetch and parse: const json = await res.json(). Then validate: const result = ApiResponseSchema.safeParse(json). Check result.success before accessing result.data — if false, result.error contains a ZodError with all validation issues. For fetch-based APIs, wrap the pattern in a utility function that returns a typed Result type. For Axios, add a response interceptor that validates every response against its schema. Always validate at the boundary where external data enters your application — never assume external JSON matches your TypeScript types without runtime validation, as API responses can change without notice.

How does Zod infer TypeScript types from schemas?

Use z.infer<typeof YourSchema> to extract the TypeScript type that a schema produces. For example: const UserSchema = z.object({ id: z.string(), age: z.number() }); type User = z.infer<typeof UserSchema>. This generates the type { id: string; age: number } at compile time with zero runtime cost — it is a pure TypeScript type-level operation. Zod distinguishes between input and output types when transforms are involved: z.input<typeof Schema> gives the type before transforms run, while z.output<typeof Schema> (equivalent to z.infer) gives the type after transforms. Use inferred types in function signatures instead of maintaining separate interface declarations — the schema and the type stay in sync automatically.

How do I handle optional and nullable fields in Zod?

Zod distinguishes between optional (field may be absent) and nullable (field may be null). Use .optional() to allow a field to be undefined or missing from the input — the inferred type adds | undefined. Use .nullable() to allow a field to be null — the inferred type adds | null. Use .nullish() to allow both null and undefined. In practice: z.string().optional() accepts "hello" or undefined; z.string().nullable() accepts "hello" or null; z.string().nullish() accepts all three. For JSON API responses where fields may be absent or null, use .nullish(). Use z.object({ ... }).partial() to make all fields optional at once — useful for PATCH request schemas based on a shared base schema.

How do I format Zod validation errors for an API response?

ZodError provides two formatting methods. error.format() returns a nested object mirroring the schema structure, where each field has a _errors array of error message strings — useful for returning structured error details to clients. error.flatten() returns { fieldErrors: Record<string, string[]>, formErrors: string[] } — a flat map of field names to error arrays, ideal for form validation responses. For HTTP 422 Unprocessable Entity responses, use error.flatten() and return { success: false, errors: error.flatten().fieldErrors } as the JSON body. For custom error messages, pass a message string to validation methods: z.string().min(3, "Must be at least 3 characters"). For global custom messages (i18n), use z.setErrorMap() or pass an error map per-parse via schema.parse(data, { errorMap }).

Can I use Zod to validate query string parameters?

Yes, and z.coerce is the key tool for this. Query string parameters are always strings, but you typically want numbers, booleans, or dates. z.coerce.number() converts "42" to 42 and fails if the string is not a valid number. z.coerce.boolean() converts "true" to true. z.coerce.date() converts date strings to Date objects. Define a query params schema: const QuerySchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().max(100).default(20) }). Then parse: QuerySchema.parse(Object.fromEntries(searchParams)). In Next.js App Router, use this pattern in Server Components or Route Handlers to safely type the searchParams object. Invalid query params return 400 with the ZodError details rather than crashing the handler.

How do I validate recursive JSON structures with Zod?

Use z.lazy() to define recursive schemas. Because TypeScript requires explicit types for recursive references, you must provide a type annotation alongside the lazy schema. For a tree node: type TreeNode = { value: string; children: TreeNode[] }. Then: const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() => z.object({ value: z.string(), children: z.array(TreeNodeSchema) })). The z.lazy() callback is called only when validation runs, breaking the circular reference at definition time. z.lazy() works with z.union() and z.discriminatedUnion() for recursive JSON with multiple node types such as AST nodes. For deeply nested structures, be aware that Zod recursively validates every node — very deep trees (1000+ levels) may hit JavaScript call stack limits. Consider adding a depth-checking refinement for untrusted input.

How do I integrate Zod with React Hook Form?

Install @hookform/resolvers and use zodResolver as the resolver option in useForm. First define your schema: const FormSchema = z.object({ email: z.string().email(), age: z.coerce.number().min(18) }). Then pass it to useForm: const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(FormSchema) }). The errors object is automatically typed to match your schema — errors.email?.message gives the string error message for the email field. React Hook Form calls zodResolver on every submission and on blur/change depending on your mode setting. Use z.infer<typeof FormSchema> as the generic type for useForm<z.infer<typeof FormSchema>> to get fully typed form values. The zodResolver passes all Zod refinements and transforms — the submit handler receives the transformed output type, not the raw input strings.

Further reading and primary sources