Files
gruperly/.agents/skills/zod/references/compose-lazy-recursive.md
2026-09-04 16:49:24 -03:00

3.2 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Use z.lazy() for Recursive Schemas MEDIUM Recursive types reference themselves before definition; z.lazy() defers evaluation to enable self-referential schemas compose, lazy, recursive, trees

Use z.lazy() for Recursive Schemas

TypeScript can't infer recursive Zod schema types automatically. Use z.lazy() to defer schema evaluation and manually provide the type annotation. This enables tree structures, nested comments, and other self-referential data.

Incorrect (direct self-reference):

import { z } from 'zod'

// This fails - categorySchema used before it's defined
const categorySchema = z.object({
  id: z.string(),
  name: z.string(),
  children: z.array(categorySchema),  // Error: Block-scoped variable used before declaration
})

Correct (using z.lazy with type annotation):

import { z } from 'zod'

// Define the type manually
interface Category {
  id: string
  name: string
  children: Category[]
}

// Use z.lazy() to defer schema reference
const categorySchema: z.ZodType<Category> = z.object({
  id: z.string(),
  name: z.string(),
  children: z.lazy(() => z.array(categorySchema)),
})

// Now it works
const tree = categorySchema.parse({
  id: '1',
  name: 'Electronics',
  children: [
    {
      id: '2',
      name: 'Phones',
      children: [
        { id: '3', name: 'iPhones', children: [] },
        { id: '4', name: 'Android', children: [] },
      ],
    },
  ],
})

Common recursive patterns:

// Comments with replies
interface Comment {
  id: string
  content: string
  author: string
  replies: Comment[]
}

const commentSchema: z.ZodType<Comment> = z.object({
  id: z.string(),
  content: z.string(),
  author: z.string(),
  replies: z.lazy(() => z.array(commentSchema)),
})

// Binary tree
interface TreeNode {
  value: number
  left: TreeNode | null
  right: TreeNode | null
}

const treeNodeSchema: z.ZodType<TreeNode> = z.object({
  value: z.number(),
  left: z.lazy(() => treeNodeSchema.nullable()),
  right: z.lazy(() => treeNodeSchema.nullable()),
})

// Nested menu structure
interface MenuItem {
  label: string
  href?: string
  children?: MenuItem[]
}

const menuItemSchema: z.ZodType<MenuItem> = z.object({
  label: z.string(),
  href: z.string().url().optional(),
  children: z.lazy(() => z.array(menuItemSchema)).optional(),
})

JSON Schema (any valid JSON):

type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue }

const jsonValueSchema: z.ZodType<JSONValue> = z.lazy(() =>
  z.union([
    z.string(),
    z.number(),
    z.boolean(),
    z.null(),
    z.array(jsonValueSchema),
    z.record(jsonValueSchema),
  ])
)

Performance consideration:

// z.lazy() has minimal overhead - the function is called once
// and the schema is cached. Safe to use in hot paths.

// If validating many recursive structures, the schema itself
// is only built once. Validation performance depends on data depth.

When NOT to use this pattern:

  • Non-recursive schemas (lazy adds unnecessary indirection)
  • When you can flatten the structure instead

Reference: Zod API - Recursive Types