How to Use Zod's Lazy for Self-Referential Schemas

Use z.lazy(() => Schema) to defer evaluation of circular type definitions, preventing JavaScript ReferenceError while preserving full TypeScript inference for recursive data structures.

Self-referential schemas are essential for validating trees, linked lists, and nested JSON structures, but standard Zod definitions fail due to JavaScript's eager evaluation. The z.lazy helper in the colinhacks/zod repository solves this by storing a getter function that executes only when the schema is first used for parsing or composition.

Why Eager Evaluation Breaks Recursive Schemas

When you attempt to reference a schema inside its own definition, JavaScript throws a ReferenceError because the variable has not finished initializing. For example, const Node = z.object({ children: z.array(Node) }) fails immediately—Node is undefined when the object literal is evaluated. This limitation prevents direct declarations for any data structure where a type references itself, including file systems, comment threads, or abstract syntax trees.

Core Implementation of Zod Lazy

The Lazy Constructor in core/api.ts

The _lazy function in packages/zod/src/v4/core/api.ts (line 1558) constructs a schema wrapper that stores a getter function rather than the schema itself. This implementation receives a class constructor and a getter arrow function, returning a ZodLazy instance with the type marker "lazy".

// packages/zod/src/v4/core/api.ts
export function _lazy<T extends schemas.$ZodType>(
  Class: util.SchemaClass<schemas.$ZodLazy>,
  getter: () => T
): schemas.$ZodLazy<T> {
  return new Class({
    type: "lazy",
    getter,
  }) as any;
}

Schema Type Markers in core/schemas.ts

The runtime identifies lazy schemas through the descriptor defined in packages/zod/src/v4/core/schemas.ts (line 4382). This type: "lazy" marker instructs Zod to invoke def.getter() whenever the concrete inner type is required for validation, ensuring deferred resolution.

// packages/zod/src/v4/core/schemas.ts
type $ZodLazy = {
  type: "lazy";
  getter: () => ZodTypeAny;
};

Mini Build Parity

The lightweight "mini" build of Zod implements identical lazy logic in packages/zod/src/v4/mini/schemas.ts (line 1699), ensuring consistent recursive type support across both full and minimal distributions.

// packages/zod/src/v4/mini/schemas.ts
function _lazy<T extends SomeType>(getter: () => T): ZodMiniLazy<T> {
  return { type: "lazy", getter };
}

Runtime Evaluation Behavior

During the definition phase, z.lazy(() => Schema) registers only the wrapper object containing the getter function. No recursive evaluation occurs at this stage. When you call parse(), safeParse(), or schema composition methods like extend(), Zod invokes the stored getter to resolve the actual schema.

The getter executes each time the lazy schema is accessed, which prevents stale references in circular structures but means the schema is recomputed on every validation pass. This design ensures safe handling of mutually recursive definitions while maintaining predictable runtime behavior.

Practical Patterns for Self-Referential Types

Self-Referencing Tree Nodes

Define hierarchical data structures like organizational charts or file systems where nodes contain arrays of themselves. Wrap the self-reference in z.lazy() and place it inside z.array() for children collections.

import { z } from "zod/v4";

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

type TreeNode = z.infer<typeof TreeNode>;

// Validates nested structures of arbitrary depth
TreeNode.parse({
  id: "root",
  value: 1,
  children: [
    { id: "child", value: 2, children: [{ id: "grandchild", value: 3 }] }
  ]
});

Mutual Recursion Between Types

When two schemas reference each other—such as a Category containing Product items that reference back to their parent—define both with z.lazy() to break the circular dependency. According to the test suite in packages/zod/src/v4/classic/tests/recursive-types.test.ts (line 71), this pattern resolves forward reference errors.

import { z } from "zod/v4";

const Category = z.object({
  name: z.string(),
  products: z.array(z.lazy(() => Product)),
});

const Product = z.object({
  title: z.string(),
  category: z.lazy(() => Category),
});

type Category = z.infer<typeof Category>;
type Product = z.infer<typeof Product>;

Recursive Union Types

Create expression languages or JSON-like structures where a value can be a primitive or a nested array of the same union type. This pattern appears in packages/zod/src/v4/core/tests/recursive-tuples.test.ts (line 7).

import { z } from "zod/v4";

const JsonValue = z.union([
  z.string(),
  z.number(),
  z.boolean(),
  z.null(),
  z.array(z.lazy(() => JsonValue)),
  z.record(z.lazy(() => JsonValue)),
]);

type JsonValue = z.infer<typeof JsonValue>;

Getter-Based Property Definitions

For cleaner API surfaces, use ES6 getter syntax within z.object() to return lazy schemas. As demonstrated in packages/zod/src/v4/classic/tests/recursive-types.test.ts (line 25), this approach eliminates top-level forward reference issues while maintaining type safety.

import { z } from "zod/v4";

const Comment = z.object({
  author: z.string(),
  text: z.string(),
  get replies() {
    return z.array(z.lazy(() => Comment)).optional();
  },
});

type Comment = z.infer<typeof Comment>;

Comment.parse({
  author: "user",
  text: "Hello",
  replies: [{ author: "other", text: "World" }]
});

Summary

  • z.lazy(() => Schema) defers evaluation of self-referential schemas in colinhacks/zod, resolving JavaScript ReferenceError during module initialization
  • The implementation stores getter functions in packages/zod/src/v4/core/api.ts, invoking them only during parsing or schema composition when the concrete type is needed
  • Apply lazy wrapping for tree structures, mutual recursion between types, deferred unions, and getter-based object properties
  • Getters execute on every access, ensuring fresh references for circular structures without caching stale definitions
  • Full TypeScript inference propagates through lazy wrappers, providing compile-time safety for recursive data structures validated at runtime

Frequently Asked Questions

What error occurs without z.lazy in recursive schemas?

JavaScript throws a ReferenceError during module initialization because the schema variable is accessed before it is fully defined. The eager evaluation of Zod's object literals prevents self-reference without the deferred wrapper.

Does z.lazy work with Zod's mini build?

Yes. The mini build in packages/zod/src/v4/mini/schemas.ts implements identical lazy functionality using the same type: "lazy" descriptor pattern, ensuring recursive schemas work consistently across both the full and lightweight distributions.

How does TypeScript handle type inference through z.lazy?

TypeScript resolves the inferred type when the getter function executes, allowing z.infer<typeof RecursiveSchema> to produce the correct recursive type definition. The lazy wrapper does not break type inference, though complex mutual recursion may require explicit type annotations in edge cases.

Is there a performance penalty for using lazy schemas?

There is zero startup overhead because the getter function remains unexecuted until first use. However, because Zod calls the getter on every access during validation according to the source implementation in packages/zod/src/v4/core/api.ts, schemas with expensive initialization logic inside the lazy function may incur repeated costs.

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 →