Рекурсивные схемы используются для описания структур данных, содержащих ссылки на самих себя. Наиболее распространённые примеры:
В обычных схемах достаточно прямого описания полей. Однако при рекурсии схема должна ссылаться сама на себя, что приводит к проблеме циклической зависимости во время инициализации.
Для решения этой задачи в Zod применяется механизм
z.lazy().
Попытка напрямую сослаться на схему внутри самой себя приводит к ошибке:
import { z } from "zod";
const CategorySchema = z.object({
name: z.string(),
children: z.array(CategorySchema)
});
Ошибка возникает потому, что CategorySchema ещё не
определена в момент обращения к ней.
JavaScript пытается прочитать переменную до завершения её инициализации.
z.lazy() откладывает вычисление схемы до момента
реального использования.
Базовый синтаксис:
z.lazy(() => схема)
Пример рекурсивной схемы:
import { z } from "zod";
const CategorySchema = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(CategorySchema)
})
);
Теперь схема корректно ссылается сама на себя.
type Category = {
name: string;
children: Category[];
};
import { z } from "zod";
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(CategorySchema)
})
);
const data = {
name: "Programming",
children: [
{
name: "JavaScript",
children: [
{
name: "Node.js",
children: []
}
]
},
{
name: "Python",
children: []
}
]
};
const result = CategorySchema.parse(data);
console.log(result);
TypeScript не всегда способен автоматически вывести тип рекурсивной
структуры. Поэтому часто применяется явное указание типа через
z.ZodType.
type Node = {
value: string;
children: Node[];
};
const NodeSchema: z.ZodType<Node> = z.lazy(() =>
z.object({
value: z.string(),
children: z.array(NodeSchema)
})
);
Без z.ZodType<Node> TypeScript может вывести
некорректный тип или потерять информацию о рекурсии.
type Comment = {
id: number;
text: string;
replies: Comment[];
};
import { z } from "zod";
type Comment = {
id: number;
text: string;
replies: Comment[];
};
const CommentSchema: z.ZodType<Comment> = z.lazy(() =>
z.object({
id: z.number(),
text: z.string(),
replies: z.array(CommentSchema)
})
);
const comments = {
id: 1,
text: "Главный комментарий",
replies: [
{
id: 2,
text: "Ответ",
replies: [
{
id: 3,
text: "Ответ на ответ",
replies: []
}
]
}
]
};
CommentSchema.parse(comments);
Рекурсия часто комбинируется с z.union().
Например, узел дерева может быть:
type FileNode = {
type: "file";
name: string;
size: number;
};
type DirectoryNode = {
type: "directory";
name: string;
children: Node[];
};
type Node = FileNode | DirectoryNode;
import { z } from "zod";
type FileNode = {
type: "file";
name: string;
size: number;
};
type DirectoryNode = {
type: "directory";
name: string;
children: Node[];
};
type Node = FileNode | DirectoryNode;
const NodeSchema: z.ZodType<Node> = z.lazy(() =>
z.union([
z.object({
type: z.literal("file"),
name: z.string(),
size: z.number()
}),
z.object({
type: z.literal("directory"),
name: z.string(),
children: z.array(NodeSchema)
})
])
);
const fileTree = {
type: "directory",
name: "src",
children: [
{
type: "file",
name: "index.ts",
size: 1200
},
{
type: "directory",
name: "components",
children: [
{
type: "file",
name: "Button.tsx",
size: 3400
}
]
}
]
};
NodeSchema.parse(fileTree);
При наличии поля-дискриминатора эффективнее использовать
z.discriminatedUnion().
import { z } from "zod";
type FileNode = {
type: "file";
name: string;
size: number;
};
type DirectoryNode = {
type: "directory";
name: string;
children: Node[];
};
type Node = FileNode | DirectoryNode;
const NodeSchema: z.ZodType<Node> = z.lazy(() =>
z.discriminatedUnion("type", [
z.object({
type: z.literal("file"),
name: z.string(),
size: z.number()
}),
z.object({
type: z.literal("directory"),
name: z.string(),
children: z.array(NodeSchema)
})
])
);
Иногда требуется ограничить уровень вложенности.
Например:
import { z } from "zod";
type Category = {
name: string;
children: Category[];
};
const MAX_DEPTH = 3;
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(CategorySchema)
})
);
function validateDepth(
node: Category,
depth = 0
): boolean {
if (depth > MAX_DEPTH) {
return false;
}
return node.children.every(child =>
validateDepth(child, depth + 1)
);
}
const LimitedCategorySchema =
CategorySchema.superRefine((value, ctx) => {
if (!validateDepth(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Превышена максимальная глубина"
});
}
});
Рекурсия может строиться не только через объекты.
type NestedArray = (string | NestedArray)[];
import { z } from "zod";
type NestedArray = (string | NestedArray)[];
const NestedArraySchema: z.ZodType<NestedArray> =
z.lazy(() =>
z.array(
z.union([
z.string(),
NestedArraySchema
])
)
);
const data = [
"a",
[
"b",
[
"c"
]
]
];
NestedArraySchema.parse(data);
AST широко используются:
type NumberNode = {
type: "number";
value: number;
};
type BinaryNode = {
type: "binary";
operator: "+" | "-" | "*" | "/";
left: Expression;
right: Expression;
};
type Expression =
| NumberNode
| BinaryNode;
import { z } from "zod";
type NumberNode = {
type: "number";
value: number;
};
type BinaryNode = {
type: "binary";
operator: "+" | "-" | "*" | "/";
left: Expression;
right: Expression;
};
type Expression =
| NumberNode
| BinaryNode;
const ExpressionSchema: z.ZodType<Expression> =
z.lazy(() =>
z.discriminatedUnion("type", [
z.object({
type: z.literal("number"),
value: z.number()
}),
z.object({
type: z.literal("binary"),
operator: z.enum(["+", "-", "*", "/"]),
left: ExpressionSchema,
right: ExpressionSchema
})
])
);
const expression = {
type: "binary",
operator: "*",
left: {
type: "number",
value: 10
},
right: {
type: "binary",
operator: "+",
left: {
type: "number",
value: 2
},
right: {
type: "number",
value: 5
}
}
};
ExpressionSchema.parse(expression);
Необязательно делать рекурсивной всю структуру.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
type TreeNode = {
owner: z.infer<typeof UserSchema>;
children: TreeNode[];
};
const TreeNodeSchema: z.ZodType<TreeNode> =
z.lazy(() =>
z.object({
owner: UserSchema,
children: z.array(TreeNodeSchema)
})
);
Рекурсивные ссылки часто бывают необязательными.
type LinkedList = {
value: number;
next: LinkedList | null;
};
import { z } from "zod";
type LinkedList = {
value: number;
next: LinkedList | null;
};
const LinkedListSchema: z.ZodType<LinkedList> =
z.lazy(() =>
z.object({
value: z.number(),
next: LinkedListSchema.nullable()
})
);
type TreeNode = {
value: string;
child?: TreeNode;
};
import { z } from "zod";
type TreeNode = {
value: string;
child?: TreeNode;
};
const TreeNodeSchema: z.ZodType<TreeNode> =
z.lazy(() =>
z.object({
value: z.string(),
child: TreeNodeSchema.optional()
})
);
Иногда две схемы ссылаются друг на друга.
type User = {
name: string;
posts: Post[];
};
type Post = {
title: string;
author: User;
};
import { z } from "zod";
type User = {
name: string;
posts: Post[];
};
type Post = {
title: string;
author: User;
};
const UserSchema: z.ZodType<User> = z.lazy(() =>
z.object({
name: z.string(),
posts: z.array(PostSchema)
})
);
const PostSchema: z.ZodType<Post> = z.lazy(() =>
z.object({
title: z.string(),
author: UserSchema
})
);
Рекурсивные структуры могут генерировать очень длинные пути ошибок.
const data = {
name: "Root",
children: [
{
name: "Child",
children: [
{
name: 123,
children: []
}
]
}
]
};
CategorySchema.safeParse(data);
Ошибка:
[
{
path: ["children", 0, "children", 0, "name"],
message: "Expected string, received number"
}
]
Для сложных вложенных структур безопаснее использовать
safeParse.
const result = CategorySchema.safeParse(data);
if (!result.success) {
console.log(result.error.format());
}
Рекурсивные схемы поддерживают преобразования.
import { z } from "zod";
type Category = {
name: string;
children: Category[];
};
const CategorySchema: z.ZodType<Category> =
z.lazy(() =>
z.object({
name: z.string().transform(v => v.trim()),
children: z.array(CategorySchema)
})
);
z.preprocess() позволяет подготовить данные до основной
проверки.
import { z } from "zod";
const NumberTreeSchema = z.lazy(() =>
z.object({
value: z.preprocess(
value => Number(value),
z.number()
),
children: z.array(NumberTreeSchema)
})
);
Рекурсивная валидация может быть дорогой операцией.
Основные факторы:
Слишком глубокая рекурсия может привести к переполнению стека:
const deep = {
value: 1,
child: {
value: 2,
child: {
value: 3
}
}
};
При тысячах уровней вложенности возможен:
RangeError: Maximum call stack size exceeded
z.discriminatedUnion(...)
работает быстрее обычного:
z.union(...)
superRefine(...)
Сложные вычисления внутри refine и
superRefine могут многократно замедлять рекурсивную
проверку.
В простых случаях возможно получение типа через
z.infer.
const TreeSchema = z.lazy(() =>
z.object({
value: z.string(),
children: z.array(TreeSchema)
})
);
type Tree = z.infer<typeof TreeSchema>;
Однако TypeScript иногда хуже обрабатывает сложную рекурсию через
infer, чем через явное объявление типа.
const BaseNodeSchema = z.object({
id: z.string()
});
type TreeNode = {
id: string;
children: TreeNode[];
};
const TreeNodeSchema: z.ZodType<TreeNode> =
z.lazy(() =>
BaseNodeSchema.extend({
children: z.array(TreeNodeSchema)
})
);
const TimestampSchema = z.object({
createdAt: z.date()
});
type Node = {
createdAt: Date;
children: Node[];
};
const NodeSchema: z.ZodType<Node> =
z.lazy(() =>
TimestampSchema.merge(
z.object({
children: z.array(NodeSchema)
})
)
);
Рекурсия особенно полезна для описания JSON.
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
import { z } from "zod";
type Json =
| string
| number
| boolean
| null
| Json[]
| { [key: string]: Json };
const JsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(JsonSchema),
z.record(JsonSchema)
])
);
Схема создаётся только при необходимости.
children: z.array(NodeSchema)
UserSchema <-> PostSchema
z.lazy() работает с:
object;union;discriminatedUnion;array;tuple;record;nullable;optional;transform;refine;preprocess.children: z.array(NodeSchema)
без:
z.lazy(...)
Схемы Zod не умеют корректно обрабатывать циклические ссылки объектов:
const obj: any = {};
obj.self = obj;
Проверка подобных структур может привести к бесконечной рекурсии.
const Schema = z.lazy(() => ...)
без:
z.ZodType<MyType>
может ухудшить типизацию.
Большие рекурсивные union способны значительно замедлять
валидацию.
Лучше использовать:
z.discriminatedUnion()
где это возможно.