【実務・中級編】関数型における「Recursive Types」を用いた、引数の階層的な型定義 – TypeScript コア・型システムの基礎解析バイブル

【TypeScript再帰型システム】無限の階層をコンパイル時で縛り上げる:ツリー構造の完全型安全設計

コードレビューをしていて、一番頭が痛くなる瞬間はどんな時か。
それは、JSONのパース結果や、UIのツリー構造、あるいはルーティングの設定オブジェクトといった「果てしない階層を持つデータ」に対して、開発者が逃げるように `any` や `Record` を貼っているのを見た時だ。

「深さが動的だから仕方ない」? いや、それはTypeScriptの型システムに対する敗北宣言に他ならない。

TypeScriptの型システムは、実はTuring完全である。つまり、型レベルで計算を行い、自己参照を行うことができる。今回は、その核心であるRecursive Types(再帰型)を駆使し、任意の深さを持つツリー構造や設定オブジェクトを完全に静的型付けする方法論を、プロダクションコードのレベルで伝授する。

—

1. なぜ「普通の型定義」ではツリー構造に太刀打ちできないのか?

まずは、愚直にオブジェクトの階層を表現しようとしたときに見舞われる「地獄」を見ておこう。

// ❌ 限界を迎える愚直な設計
interface NodeLevel2 {
value: string;
}

interface NodeLevel1 {
value: string;
child?: NodeLevel2;
}

interface RootNode {
value: string;
child?: NodeLevel1;
}

このアプローチの欠点は明白だ。「深さが3階層固定」になっている。
もしバックエンドの仕様変更で階層が4つになったり、ユーザーが動的にメニューを追加できるUIライブラリを作ったりした場合、このコードは一巻の終わりだ。

では、よくある「緩い」定義はどうだろうか。

// ❌ 最悪のアンチパターン(型安全の放棄)
interface LooseNode {
value: string;
children?: LooseNode[]; // 一見動的に見えるが…
}

これでも動くには動くが、実務の現場では致命的な問題がある。
「特定の階層から特定のプロパティを除外したい」「パス(例: `settings.theme.colors`)をドット区切りで補完させたい」と思った瞬間、この `LooseNode[]` は情報量が少なすぎて、TypeScriptの強力な推論を何も引き出せなくなる。

—

2. 再帰型(Recursive Types)による真の階層モデル構築

ここからが本題だ。自己参照型を利用し、あらゆる深さに耐えうる汎用的なツリー構造の引数型を構築する。

実務でそのまま使える、「権限・アクセス制御付きUIメニューツリー」の設計例を見てほしい。

/

  • 厳格な再帰型によるメニューツリーの定義

/
export type Permission = ‘read’ | ‘write’ | ‘admin’;

export interface MenuTreeNode> {
/ ノードを一意に特定するID /
readonly id: string;
/ 表示ラベル /
readonly label: string;
/ このノードにアクセスするために必要な権限(省略時は制限なし) /
readonly requiredPermission?: Permission;
/ 任意のメタデータ(ペイロード) /
readonly data?: TData;
/ 子ノード群(自分自身を再帰的に内包) /
readonly children?: readonly MenuTreeNode[5]; // ⚠️ 待て、ここをどう定義すべきか?
}

ちょっと待て。ここでプロのTypeScriptエンジニアとして立ち止まらなければならない。
`children?: readonly MenuTreeNode[]` と書くのは簡単だが、これでは「配列の深さ制限」や「パフォーマンスの最適化」という、コンパイラの裏側で起きている問題に対応できない。

—

3. コンパイラを殺さないための「再帰の深さ制御」とパフォーマンス

TypeScriptの型推論において、無制限の再帰はコンパイラに過度な負荷をかけ、最悪の場合 `Type instantiation is excessively deep and possibly infinite.`(型のインスタンス化が過度に深いため、無限の可能性があります) というコンパイルエラーを引き起こす。また、IDE(VSCodeなど)のLanguage Serverのレスポンスが極端に悪化し、開発者体験(DX)が死に絶える。

これを防ぐためのテクニックが、「カウンタ(深度カウンター)を用いた再帰の打ち切り」だ。

深度を制限した堅牢な再帰型パターン

// カウント用のタプル型(最大深度を5階層に制限する例)
type Prev = [never, 0, 1, 2, 3, 4, 5];

/

  • 深度制限付きの安全なツリーノード型
  • @template TData ノードに持たせるペイロードの型
  • @template TDepth 残り許容深度(デフォルトは最大値の5)

/
export type BoundedTreeNode< TData = unknown, TDepth extends number = 5 > = 5 extends TDepth
? never // 深度オーバーフロー時のガード
: {
readonly id: string;
readonly path: string;
readonly data: TData;
// 深度が0でない場合のみ、childrenを型定義に含める
readonly children?: TDepth extends 0
? never
: readonly BoundedTreeNode[];
};

このパターンの何が美しいか?
1. 無限再帰の完全防止: コンパイラが無限ループに陥るのを防ぎ、IDEが重くなる現象を根絶する。
2. 保守性の担保: 「このコンポーネントが許容するツリー構造の深さは最大5階層まで」というビジネスロジックや設計上の制約を、型レベルでコード化できる。

—

4. 【実践】階層型引数を受け取る再帰関数の実装と型推論

型が組み上がったら、次はそれを安全に処理する「実行時コード」だ。再帰型を受け取る関数は、当然ながら実装も再帰(またはスタックを用いた走査)になる。

ここでは、定義されたツリー構造を安全に走査し、特定の条件に一致するノードを抽出するユーティリティ関数のプロダクションコードを示す。

/

  • ツリー構造を再帰的に走査し、条件に一致するノードのパスを収集する
  • @param node 処理対象のノード(BoundedTreeNodeのいずれかの階層)
  • @param predicate フィルタリング条件
  • @param accumulator 内部集計用バッファ

/
export function findNodePaths(
node: BoundedTreeNode,
predicate: (node: BoundedTreeNode) => boolean,
accumulator: string[] = []
): string[] {
// 現在のノードが条件に合致するか判定
if (predicate(node)) {
accumulator.push(node.path);
}

// 子ノードが存在する場合のみ再帰的に走査
if (node.children && node.children.length > 0) {
for (const child of node.children) {
findNodePaths(child, predicate, accumulator);
}
}

return accumulator;
}

このコードの強み

引数 `node` には `BoundedTreeNode` が指定されているため、呼び出し側でどのような階層のオブジェクトを渡そうとも、TypeScriptは構造的型付け(Structural Subtyping)により完璧に型を検証する。存在しないプロパティにアクセスしようものなら、即座にコンパイルエラーが開発者の手を止める。

—

5. チーフアーキテクトからの提言:実務で型を崩壊させないための心構え

1. `any` や `Record` で思考停止しない
動的な階層データであっても、最大深度や必須プロパティの共通項を見出し、今回紹介したような「深度カウンター付き再帰型」を適用せよ。型エラーを恐れて `any` を逃げ込むコードは、半年後のチームへの負債でしかない。
2. コンパイラの悲鳴に耳を傾けろ
もしIDEが重くなったり、型チェックが異常に遅くなったりした場合は、再帰型が無限ループしているか、深さの許容値(`Prev` 配列の長さ)が大きすぎないかを疑え。
3. ランタイムのバリデーションを忘れるな
TypeScriptの型はコンパイル時で消え去る。バックエンドや外部APIから渡ってくるJSONツリーが本当にその型を満たしているかは、`Zod` などのランタイムバリデーションライブラリの再帰スキーマと組み合わせることで初めて「完全な堅牢性」が手に入る。

型システムは、開発者を縛る足枷ではない。「未来のバグからプロダクトを守るための最強の防壁」だ。
このアプローチをコードベースに導入し、自信を持ってプルリクエストを出してほしい。

タイトルとURLをコピーしました