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.
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.