Zod の再帰型・木構造設計:`z.lazy` でツリー/グラフを安全に表現
TypeScript でデータを扱う際、ツリー構造やグラフのような再帰的なデータを型安全に扱いたいと思ったことはありませんか。実は Zod の z.lazy を使えば、複雑な木構造も安全に表現できます。
この記事では、Zod で再帰的なデータ構造を設計する方法を、実践的なコード例とともにご紹介します。ファイルシステムや組織図、コメントツリーなど、実際のアプリケーションで使える技術が身につくでしょう。
背景
再帰的なデータ構造の必要性
Web アプリケーション開発では、階層構造を持つデータを扱う機会が多くあります。たとえば、フォルダとファイルの関係、組織の階層、SNS のコメント返信など、親子関係が連鎖する構造は至る所に存在しますね。
こうしたデータは「自分自身を子要素として含む」という特徴があり、これを再帰的なデータ構造と呼びます。
Zod とは
Zod は TypeScript のための軽量なスキーマバリデーションライブラリです。ランタイムでのデータ検証と、TypeScript の型推論を同時に実現できる点が魅力でしょう。
API から受け取ったデータや、ユーザーからの入力値を安全に扱うために、多くのプロジェクトで採用されています。
以下の図は、典型的な木構造の例を示しています。
mermaidflowchart TB
root["ルートノード<br/>(Root)"]
child1["子ノード A"]
child2["子ノード B"]
child3["子ノード C"]
grandchild1["孫ノード A-1"]
grandchild2["孫ノード A-2"]
grandchild3["孫ノード B-1"]
root --> child1
root --> child2
root --> child3
child1 --> grandchild1
child1 --> grandchild2
child2 --> grandchild3
上図のように、各ノードが子ノードを持ち、その子ノードがさらに子を持つ、という構造が再帰的データの基本形です。
図で理解できる要点
- ルートノードから複数の子ノードへ枝分かれする
- 各子ノードがさらに子を持つことで階層が深くなる
- この構造は理論上無限に続く可能性がある
課題
TypeScript での循環参照の制限
TypeScript で再帰的な型を定義しようとすると、すぐに問題に直面します。以下のコードは一見正しそうに見えますが、実際にはエラーになってしまいますね。
typescript// ❌ このコードはエラーになります
import { z } from 'zod';
const TreeNode = z.object({
name: z.string(),
children: z.array(TreeNode), // Error: 'TreeNode' is referenced directly before being completely defined
});
このコードが失敗する理由は、TreeNode が定義される前に自分自身を参照しているためです。
Zod における循環参照の問題
Zod のスキーマは通常、定義時に即座に評価されます。つまり、z.object() を呼び出した時点で、すべてのプロパティが確定している必要があるのです。
しかし再帰的な構造では「まだ定義が完了していない自分自身」を参照する必要があり、これが矛盾を生みます。
以下の図は、この循環参照の問題を示しています。
mermaidflowchart LR
definition["TreeNode の定義開始"]
name["name プロパティ定義"]
children["children プロパティ"]
reference["TreeNode を参照"]
error["エラー発生<br/>未定義の参照"]
definition --> name
name --> children
children --> reference
reference -.->|まだ未完成| error
style error fill:#ffcccc
この図から分かるように、定義の途中で自分自身を参照しようとすると、まだ定義が完成していないためエラーになります。
実際の開発における影響
この制限により、以下のような実装が困難になります:
| # | データ構造 | 説明 |
|---|---|---|
| 1 | ファイルシステム | フォルダが子フォルダを含む構造 |
| 2 | 組織図 | 部門が下位部門を持つ階層 |
| 3 | コメントツリー | コメントに対する返信の連鎖 |
| 4 | カテゴリ階層 | カテゴリが子カテゴリを持つ構造 |
| 5 | JSON ツリー | ネストした JSON オブジェクト |
これらすべてに共通するのは「自己参照」が必要という点です。
解決策
z.lazy による遅延評価
Zod は z.lazy という関数を提供しており、これが循環参照の問題を解決します。z.lazy はスキーマの評価を遅延させ、実際に必要になるまで定義を先送りするのです。
以下のコードで、基本的な使い方を見てみましょう。
typescriptimport { z } from 'zod';
// z.lazy を使った再帰型の定義
const TreeNode: z.ZodType<TreeNodeType> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(TreeNode),
})
);
typescript// 型の定義(TypeScript 側)
type TreeNodeType = {
name: string;
children: TreeNodeType[];
};
ここで重要なのは、z.lazy が関数を受け取り、その関数が実際のスキーマを返すという点です。関数の中では既に TreeNode が宣言されているため、安全に参照できますね。
z.lazy の動作原理
z.lazy は以下のような仕組みで動作します。
mermaidsequenceDiagram
participant Code as アプリコード
participant Zod as Zod エンジン
participant Lazy as z.lazy 関数
participant Schema as スキーマ定義
Code->>Zod: データ検証開始
Zod->>Lazy: スキーマが必要
Lazy->>Schema: 関数を実行してスキーマ取得
Schema-->>Lazy: スキーマを返す
Lazy-->>Zod: 評価済みスキーマ
Zod->>Zod: 子要素の検証<br/>(再帰的に繰り返し)
Zod-->>Code: 検証結果を返す
この図が示すように、スキーマの評価は実際にバリデーションが実行されるタイミングまで遅延されます。そのため、定義時の循環参照問題を回避できるのです。
型注釈の重要性
z.lazy を使う際は、必ず型注釈を付けることが推奨されます。
typescript// ✅ 推奨:型注釈あり
const TreeNode: z.ZodType<TreeNodeType> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(TreeNode),
})
);
typescript// ⚠️ 型注釈なしでも動作するが推奨されない
const TreeNode = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(TreeNode),
})
);
型注釈がないと、TypeScript の型推論が循環してしまい、エディタの補完が正しく動作しない場合があります。
オプショナルな子要素
実際のアプリケーションでは、子要素を持たないノード(葉ノード)も存在します。そのため、children を optional にすることが一般的でしょう。
typescriptconst TreeNode: z.ZodType<TreeNodeType> = z.lazy(() =>
z.object({
name: z.string(),
children: z.array(TreeNode).optional(), // optional を追加
})
);
typescripttype TreeNodeType = {
name: string;
children?: TreeNodeType[]; // optional に対応
};
これにより、葉ノードでは children プロパティを省略できるようになります。
具体例
ファイルシステムの実装
最も実用的な例として、ファイルとフォルダを表現するスキーマを作成してみましょう。
型定義
まず TypeScript の型を定義します。
typescript// ファイルとフォルダの共通プロパティ
type FileSystemItem = {
name: string;
type: 'file' | 'folder';
size?: number; // ファイルの場合のみ
children?: FileSystemItem[]; // フォルダの場合のみ
};
Zod スキーマの定義
次に、Zod でバリデーションスキーマを作成します。
typescriptimport { z } from 'zod';
const FileSystemItem: z.ZodType<FileSystemItem> = z.lazy(
() =>
z.object({
name: z.string().min(1, 'ファイル名は必須です'),
type: z.enum(['file', 'folder']),
size: z.number().positive().optional(),
children: z.array(FileSystemItem).optional(),
})
);
このスキーマは、ファイル名の存在チェックやサイズの正数チェックなど、実用的なバリデーションを含んでいます。
データの検証
実際にデータを検証してみましょう。
typescript// 検証するデータ
const fileSystem = {
name: 'root',
type: 'folder' as const,
children: [
{
name: 'documents',
type: 'folder' as const,
children: [
{
name: 'report.pdf',
type: 'file' as const,
size: 1024,
},
{
name: 'memo.txt',
type: 'file' as const,
size: 256,
},
],
},
{
name: 'images',
type: 'folder' as const,
children: [
{
name: 'photo.jpg',
type: 'file' as const,
size: 2048,
},
],
},
],
};
typescript// バリデーション実行
try {
const validated = FileSystemItem.parse(fileSystem);
console.log('検証成功:', validated);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('検証エラー:', error.errors);
}
}
このコードでは、ネストした構造全体が再帰的に検証されます。どの階層でもバリデーションルールが適用されるため、安全性が保証されますね。
以下の図は、実装したファイルシステムの構造を示しています。
mermaidflowchart TB
root["root<br/>(folder)"]
documents["documents<br/>(folder)"]
images["images<br/>(folder)"]
report["report.pdf<br/>(file, 1024B)"]
memo["memo.txt<br/>(file, 256B)"]
photo["photo.jpg<br/>(file, 2048B)"]
root --> documents
root --> images
documents --> report
documents --> memo
images --> photo
style report fill:#e1f5ff
style memo fill:#e1f5ff
style photo fill:#e1f5ff
組織階層の表現
次に、会社の組織構造を表現するスキーマを作成します。
型とスキーマの定義
typescript// 組織の型定義
type Organization = {
id: string;
name: string;
manager: string;
members: string[];
subDepartments?: Organization[];
};
typescriptimport { z } from 'zod';
// Zod スキーマ
const Organization: z.ZodType<Organization> = z.lazy(() =>
z.object({
id: z.string().uuid('有効な UUID が必要です'),
name: z.string().min(1, '部門名は必須です'),
manager: z.string().min(1, '責任者名は必須です'),
members: z.array(z.string()).default([]),
subDepartments: z.array(Organization).optional(),
})
);
組織データの例
typescriptconst companyStructure = {
id: '00000000-0000-0000-0000-000000000001',
name: '株式会社サンプル',
manager: '山田太郎',
members: ['山田太郎'],
subDepartments: [
{
id: '00000000-0000-0000-0000-000000000002',
name: '開発部',
manager: '佐藤花子',
members: ['佐藤花子', '鈴木一郎', '田中二郎'],
subDepartments: [
{
id: '00000000-0000-0000-0000-000000000003',
name: 'フロントエンドチーム',
manager: '鈴木一郎',
members: ['鈴木一郎', '高橋三郎'],
},
],
},
],
};
typescript// 検証
const validatedOrg = Organization.parse(companyStructure);
console.log('組織データ検証成功');
このように、複雑な階層構造も z.lazy を使えば簡潔に表現できます。
コメントツリーの実装
SNS やブログのコメント返信機能も、再帰的な構造の典型例です。
スキーマ定義
typescripttype Comment = {
id: string;
author: string;
content: string;
createdAt: Date;
replies?: Comment[];
};
typescriptimport { z } from 'zod';
const Comment: z.ZodType<Comment> = z.lazy(() =>
z.object({
id: z.string(),
author: z.string().min(1, '投稿者名は必須です'),
content: z
.string()
.min(1, 'コメント本文は必須です')
.max(1000, '1000文字以内で入力してください'),
createdAt: z.date(),
replies: z.array(Comment).optional(),
})
);
使用例
typescriptconst commentThread = {
id: 'c1',
author: '太郎',
content: 'この記事参考になりました!',
createdAt: new Date('2025-01-15T10:00:00'),
replies: [
{
id: 'c2',
author: '花子',
content: 'ありがとうございます!',
createdAt: new Date('2025-01-15T11:00:00'),
replies: [
{
id: 'c3',
author: '太郎',
content: '続編も楽しみにしています',
createdAt: new Date('2025-01-15T12:00:00'),
},
],
},
],
};
typescript// バリデーション
try {
const validated = Comment.parse(commentThread);
console.log('コメントツリー検証成功');
} catch (error) {
console.error('エラー:', error);
}
コメントの文字数制限や必須チェックが、すべての階層で自動的に適用されます。
より複雑なグラフ構造
場合によっては、単純な木構造ではなく、循環を含むグラフ構造が必要になることもあります。
相互参照を含むノード
typescripttype GraphNode = {
id: string;
label: string;
connections: string[]; // 他ノードの ID を参照
};
typescriptimport { z } from 'zod';
// グラフノードのスキーマ
const GraphNode = z.object({
id: z.string(),
label: z.string(),
connections: z.array(z.string()), // ID の配列
});
typescript// グラフ全体のスキーマ
const Graph = z.object({
nodes: z.array(GraphNode),
});
グラフデータの例
typescriptconst graphData = {
nodes: [
{ id: 'A', label: 'ノードA', connections: ['B', 'C'] },
{ id: 'B', label: 'ノードB', connections: ['C'] },
{ id: 'C', label: 'ノードC', connections: ['A'] }, // 循環参照
],
};
typescript// 検証
const validatedGraph = Graph.parse(graphData);
// 参照の整合性チェック(カスタムバリデーション)
const nodeIds = new Set(
validatedGraph.nodes.map((n) => n.id)
);
const allValid = validatedGraph.nodes.every((node) =>
node.connections.every((connId) => nodeIds.has(connId))
);
if (!allValid) {
throw new Error('存在しないノードへの参照があります');
}
このように、ID による参照を使えば循環を含むグラフ構造も表現できますね。
デフォルト値と変換
z.lazy は他の Zod 機能と組み合わせることもできます。
デフォルト値の設定
typescriptconst TreeNodeWithDefaults: z.ZodType<TreeNodeType> =
z.lazy(() =>
z.object({
name: z.string().default('untitled'),
children: z.array(TreeNodeWithDefaults).default([]),
})
);
typescript// children が未指定でも空配列がセットされる
const node = TreeNodeWithDefaults.parse({ name: 'root' });
console.log(node.children); // []
データ変換(transform)
typescriptconst TreeNodeWithTransform: z.ZodType<any> = z.lazy(() =>
z.object({
name: z.string().transform((val) => val.trim()),
children: z.array(TreeNodeWithTransform).optional(),
})
);
typescript// 名前の前後の空白が自動削除される
const result = TreeNodeWithTransform.parse({
name: ' root ',
children: [{ name: ' child ' }],
});
console.log(result.name); // 'root'
console.log(result.children[0].name); // 'child'
このように、z.lazy と他の Zod 機能を組み合わせることで、強力なバリデーションが実現できます。
まとめ
Zod の z.lazy を使うことで、ツリーやグラフのような再帰的なデータ構造を型安全に扱えることがお分かりいただけたでしょうか。
本記事で学んだ内容を振り返りましょう:
| # | 項目 | 内容 |
|---|---|---|
| 1 | 背景 | 再帰的データ構造は Web アプリで頻出する |
| 2 | 課題 | TypeScript では循環参照が制限される |
| 3 | 解決策 | z.lazy で評価を遅延させる |
| 4 | 実装 | ファイルシステム、組織図、コメントツリーなどに応用可能 |
| 5 | 応用 | デフォルト値や変換と組み合わせ可能 |
z.lazy はシンプルながら非常に強力な機能です。この技術を使えば、API レスポンスの検証やフォーム入力のバリデーションなど、実際の開発現場で即座に活用できますね。
特に、外部 API から受け取る階層的なデータを扱う際には、型安全性が開発効率と品質の向上に直結します。ぜひ今日から z.lazy を活用して、より堅牢な TypeScript アプリケーションを構築してください。
次のステップとして、実際のプロジェクトで再帰的なデータ構造を見つけたら、z.lazy での実装にチャレンジしてみることをおすすめします。
関連リンク
articleZod の再帰型・木構造設計:`z.lazy` でツリー/グラフを安全に表現
articleZod 合成パターン早見表:`object/array/tuple/record/map/set/intersection` 実例集
articleDeno/Bun/Node のランタイムで共通動く Zod 環境のセットアップ
articleZod で“境界”を守る設計思想:IO バリデーションと型推論の二刀流
articleZod スキーマのバージョニング運用:SemVer・互換レイヤー・段階移行の実践
articleZod で「never に推論される」問題の原因と対処:`narrowing` と `as const`
articleMongoDB が遅い原因を一発特定:`explain()`・プロファイラ・統計の使い方
articleApollo Client の正規化設計:`keyFields`/`typePolicies` で ID 設計を固定化
articleCursor コスト最適化:トークン節約・キャッシュ・差分駆動で費用を半減
articleZod の再帰型・木構造設計:`z.lazy` でツリー/グラフを安全に表現
articleCline ガバナンス運用:ポリシー・承認フロー・監査証跡の整備
articleYarn の歴史と進化:Classic(v1) から Berry(v2/v4) まで一気に把握
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来