144 lines
3.2 KiB
Markdown
144 lines
3.2 KiB
Markdown
---
|
|
title: Use z.lazy() for Recursive Schemas
|
|
impact: MEDIUM
|
|
impactDescription: Recursive types reference themselves before definition; z.lazy() defers evaluation to enable self-referential schemas
|
|
tags: compose, lazy, recursive, trees
|
|
---
|
|
|
|
## Use z.lazy() for Recursive Schemas
|
|
|
|
TypeScript can't infer recursive Zod schema types automatically. Use `z.lazy()` to defer schema evaluation and manually provide the type annotation. This enables tree structures, nested comments, and other self-referential data.
|
|
|
|
**Incorrect (direct self-reference):**
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
|
|
// This fails - categorySchema used before it's defined
|
|
const categorySchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
children: z.array(categorySchema), // Error: Block-scoped variable used before declaration
|
|
})
|
|
```
|
|
|
|
**Correct (using z.lazy with type annotation):**
|
|
|
|
```typescript
|
|
import { z } from 'zod'
|
|
|
|
// Define the type manually
|
|
interface Category {
|
|
id: string
|
|
name: string
|
|
children: Category[]
|
|
}
|
|
|
|
// Use z.lazy() to defer schema reference
|
|
const categorySchema: z.ZodType<Category> = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
children: z.lazy(() => z.array(categorySchema)),
|
|
})
|
|
|
|
// Now it works
|
|
const tree = categorySchema.parse({
|
|
id: '1',
|
|
name: 'Electronics',
|
|
children: [
|
|
{
|
|
id: '2',
|
|
name: 'Phones',
|
|
children: [
|
|
{ id: '3', name: 'iPhones', children: [] },
|
|
{ id: '4', name: 'Android', children: [] },
|
|
],
|
|
},
|
|
],
|
|
})
|
|
```
|
|
|
|
**Common recursive patterns:**
|
|
|
|
```typescript
|
|
// Comments with replies
|
|
interface Comment {
|
|
id: string
|
|
content: string
|
|
author: string
|
|
replies: Comment[]
|
|
}
|
|
|
|
const commentSchema: z.ZodType<Comment> = z.object({
|
|
id: z.string(),
|
|
content: z.string(),
|
|
author: z.string(),
|
|
replies: z.lazy(() => z.array(commentSchema)),
|
|
})
|
|
|
|
// Binary tree
|
|
interface TreeNode {
|
|
value: number
|
|
left: TreeNode | null
|
|
right: TreeNode | null
|
|
}
|
|
|
|
const treeNodeSchema: z.ZodType<TreeNode> = z.object({
|
|
value: z.number(),
|
|
left: z.lazy(() => treeNodeSchema.nullable()),
|
|
right: z.lazy(() => treeNodeSchema.nullable()),
|
|
})
|
|
|
|
// Nested menu structure
|
|
interface MenuItem {
|
|
label: string
|
|
href?: string
|
|
children?: MenuItem[]
|
|
}
|
|
|
|
const menuItemSchema: z.ZodType<MenuItem> = z.object({
|
|
label: z.string(),
|
|
href: z.string().url().optional(),
|
|
children: z.lazy(() => z.array(menuItemSchema)).optional(),
|
|
})
|
|
```
|
|
|
|
**JSON Schema (any valid JSON):**
|
|
|
|
```typescript
|
|
type JSONValue =
|
|
| string
|
|
| number
|
|
| boolean
|
|
| null
|
|
| JSONValue[]
|
|
| { [key: string]: JSONValue }
|
|
|
|
const jsonValueSchema: z.ZodType<JSONValue> = z.lazy(() =>
|
|
z.union([
|
|
z.string(),
|
|
z.number(),
|
|
z.boolean(),
|
|
z.null(),
|
|
z.array(jsonValueSchema),
|
|
z.record(jsonValueSchema),
|
|
])
|
|
)
|
|
```
|
|
|
|
**Performance consideration:**
|
|
|
|
```typescript
|
|
// z.lazy() has minimal overhead - the function is called once
|
|
// and the schema is cached. Safe to use in hot paths.
|
|
|
|
// If validating many recursive structures, the schema itself
|
|
// is only built once. Validation performance depends on data depth.
|
|
```
|
|
|
|
**When NOT to use this pattern:**
|
|
- Non-recursive schemas (lazy adds unnecessary indirection)
|
|
- When you can flatten the structure instead
|
|
|
|
Reference: [Zod API - Recursive Types](https://zod.dev/api#recursive-types)
|