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

> Master recursive types in Zod with z.lazy Learn to define self-referencing schemas and break circular dependencies for robust data validation in your TypeScript projects

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

---

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

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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`](https://github.com/colinhacks/zod/blob/main/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`](https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/api.ts) for the main build and [`packages/zod/src/v4/mini/schemas.ts`](https://github.com/colinhacks/zod/blob/main/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`](https://github.com/colinhacks/zod/blob/main/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`](https://github.com/colinhacks/zod/blob/main/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.