T-CREATOR

Zod の再帰型・木構造設計:`z.lazy` でツリー/グラフを安全に表現

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カテゴリ階層カテゴリが子カテゴリを持つ構造
5JSON ツリーネストした 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 での実装にチャレンジしてみることをおすすめします。

関連リンク