Create Recursive Zod Schemas from Self-Referencing JSON

Learn how to define recursive Zod schemas using z.lazy() for tree structures, comment threads, and nested menus from JSON data.

Advanced

Detailed Explanation

Recursive Schemas in Zod

Many data models are inherently recursive: file systems, comment threads, org charts, and navigation menus all contain nodes that reference the same schema.

Example JSON

{
  "id": 1,
  "name": "Root",
  "children": [
    {
      "id": 2,
      "name": "Child A",
      "children": [
        { "id": 4, "name": "Grandchild", "children": [] }
      ]
    },
    {
      "id": 3,
      "name": "Child B",
      "children": []
    }
  ]
}

Recursive Zod Schema

import { z } from "zod";

interface TreeNode {
  id: number;
  name: string;
  children: TreeNode[];
}

const treeNodeSchema: z.ZodType<TreeNode> = z.object({
  id: z.number().int(),
  name: z.string(),
  children: z.lazy(() => z.array(treeNodeSchema)),
});

Why z.lazy() Is Necessary

JavaScript evaluates expressions eagerly. Without z.lazy(), the schema would reference itself before it is defined, causing a ReferenceError. z.lazy() defers evaluation until parsing time.

Comment Thread Example

interface Comment {
  id: number;
  author: string;
  text: string;
  replies: Comment[];
}

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

Type Annotation Requirement

Notice the explicit type annotation z.ZodType<TreeNode>. This is required for recursive schemas because TypeScript cannot infer the type of a schema that references itself. You must define the interface separately and annotate the schema variable.

Depth-Limited Parsing

For safety, you can limit recursion depth with a custom check:

function createTreeSchema(maxDepth: number): z.ZodTypeAny {
  if (maxDepth <= 0) {
    return z.object({
      id: z.number(),
      name: z.string(),
      children: z.array(z.never()),
    });
  }
  return z.object({
    id: z.number(),
    name: z.string(),
    children: z.array(z.lazy(() => createTreeSchema(maxDepth - 1))),
  });
}

const shallowTreeSchema = createTreeSchema(5);

Mutually Recursive Schemas

interface Folder {
  name: string;
  files: FileNode[];
  subfolders: Folder[];
}
interface FileNode {
  name: string;
  size: number;
}

const fileSchema = z.object({
  name: z.string(),
  size: z.number(),
});

const folderSchema: z.ZodType<Folder> = z.object({
  name: z.string(),
  files: z.array(fileSchema),
  subfolders: z.lazy(() => z.array(folderSchema)),
});

Use Case

You are building a comment system where each comment can have nested replies at arbitrary depth, and you need runtime validation that ensures the entire tree conforms to the expected shape.

Try It — JSON to Zod Schema

Open full tool