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

> Learn to use Zod's lazy for self-referential schemas. Defer evaluation of circular types with z.lazy to prevent ReferenceError and maintain TypeScript inference for recursive data.

- Repository: [Colin McDonnell/zod](https://github.com/colinhacks/zod)
- Tags: how-to-guide
- Published: 2026-02-23

---

**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`](https://github.com/colinhacks/zod/blob/main/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"`.

```typescript
// 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`](https://github.com/colinhacks/zod/blob/main/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.

```typescript
// 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`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/mini/schemas.ts) (line 1699), ensuring consistent recursive type support across both full and minimal distributions.

```typescript
// 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.

```typescript
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`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/tests/recursive-types.test.ts) (line 71), this pattern resolves forward reference errors.

```typescript
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`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/tests/recursive-tuples.test.ts) (line 7).

```typescript
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`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/classic/tests/recursive-types.test.ts) (line 25), this approach eliminates top-level forward reference issues while maintaining type safety.

```typescript
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`](https://github.com/colinhacks/zod/blob/main/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`](https://github.com/colinhacks/zod/blob/main/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`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/api.ts), schemas with expensive initialization logic inside the lazy function may incur repeated costs.