How to Define Recursive Types in Zod: A Complete Guide to z.lazy

Use z.lazy with a getter function to defer schema evaluation and break circular dependencies when defining self-referencing or mutually-referencing schemas in the colinhacks/zod library.

Defining recursive types in Zod requires special handling to avoid runtime circular reference errors. The library provides the z.lazy helper to create deferred schema definitions that maintain full TypeScript type inference while supporting complex nested structures like trees, linked lists, and mutually dependent types.

Why Recursive Types Require Lazy Evaluation

When a schema attempts to reference itself directly, JavaScript evaluates the definition immediately, creating an impossible circular dependency that throws a runtime error. The z.lazy function solves this by accepting a getter—a function that returns the actual schema only when needed during parsing.

According to the Zod source code, the z.lazy factory is implemented in packages/zod/src/v4/core/api.ts (lines 1555-1566), where it creates a schema object with type: "lazy" and stores the getter function for deferred execution. Internally, the core runtime calls this stored getter to obtain the concrete schema during validation, as defined in packages/zod/src/v4/core/schemas.ts (lines 4382-4385). A lightweight implementation also exists in the mini build at packages/zod/src/v4/mini/schemas.ts (lines 1699-1704).

How to Define Self-Referencing Schemas

Self-recursion occurs when a schema contains a field of its own type, commonly seen in tree structures or nested categories.

Tree Structures with Property Getters

Use JavaScript getter syntax to defer access to the schema until after initialization:

import { z } from "zod";

const Category = z.object({
  name: z.string(),
  get subcategories() {
    return z.array(Category).optional().nullable();
  },
});

type Category = z.infer<typeof Category>;
// { name: string; subcategories?: Category[] | null }

Category.parse({
  name: "Root",
  subcategories: [{ name: "Child", subcategories: [] }],
});

This pattern leverages property getters to prevent immediate evaluation, allowing the Category identifier to exist before it is referenced within the array definition.

Direct z.lazy Syntax

Alternatively, use z.lazy explicitly within any schema definition:

import { z } from "zod";

const Node = z.object({
  id: z.string(),
  children: z.lazy(() => z.array(Node)).optional(),
});

Node.parse({ id: "root", children: [{ id: "leaf" }] });

This syntax works inside any Zod schema and is particularly useful when defining recursive arrays or optional nested fields without getter properties.

How to Define Mutually Recursive Types

Mutual recursion involves two or more schemas that reference each other. Define each schema using getters that reference the partner schema:

import { z } from "zod";

const Alazy = z.object({
  val: z.number(),
  get b() {
    return Blazy;
  },
});

const Blazy = z.object({
  val: z.number(),
  get a() {
    return Alazy.optional();
  },
});

type A = z.infer<typeof Alazy>;
type B = z.infer<typeof Blazy>;

Alazy.parse({ val: 1, b: { val: 2 } });

In this example, Alazy references Blazy before it is fully defined, and vice versa. The getter functions ensure both schemas exist before either is evaluated during parsing.

Recursive Unions and Linked Lists

Recursive unions allow defining structures like linked lists where each node may contain another node of the same type:

import { z } from "zod";

const List = z.object({
  value: z.number(),
  get next() {
    return List.nullable();
  },
});

type List = z.infer<typeof List>;
// { value: number; next: List | null }

List.parse({ value: 1, next: { value: 2, next: null } });

This pattern appears in the Zod test suite at packages/zod/src/v4/classic/tests/recursive-types.test.ts (lines 55-60), demonstrating how nullable recursive fields terminate the recursion chain.

Summary

  • Use z.lazy or property getters to defer schema evaluation and prevent circular reference errors when defining recursive types in Zod.
  • Self-recursion requires wrapping self-references in getter functions or z.lazy callbacks to delay evaluation until parse time.
  • Mutual recursion between multiple schemas works by defining each schema with getters that reference the others, breaking the circular dependency.
  • The implementation resides in packages/zod/src/v4/core/api.ts for the main build and packages/zod/src/v4/mini/schemas.ts for the lightweight version.
  • All recursive patterns are validated against the comprehensive test suite in packages/zod/src/v4/classic/tests/recursive-types.test.ts.

Frequently Asked Questions

What is z.lazy in Zod?

z.lazy is a factory function that creates a deferred schema definition by accepting a getter function. It stores this function internally and executes it only during parsing, allowing schemas to reference themselves or each other without causing immediate circular reference errors. The function is exported from packages/zod/src/v4/core/api.ts and creates a schema with type: "lazy".

Can I use z.lazy with TypeScript interfaces?

Yes, z.lazy maintains full TypeScript type inference. When you use z.infer<typeof Schema> on a lazy schema, TypeScript correctly resolves the recursive type structure, generating accurate type definitions for self-referencing objects, trees, and linked lists without manual type annotations.

How do I debug circular reference errors in Zod?

If you encounter "Cannot access before initialization" errors, ensure you are using getter syntax (get propertyName()) or z.lazy(() => ...) instead of direct property assignment. Directly assigning a schema to a property that references the parent schema causes immediate evaluation, while getters defer evaluation until the schema is actually used for parsing.

Does z.lazy affect runtime performance?

The performance impact is minimal. z.lazy adds a single function call overhead during schema initialization and parsing. The getter executes only when the specific field is accessed during validation, making it suitable for production applications handling deeply nested recursive data structures.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →