こんにちは!TypeScriptの型システムを旅するフルスタックエンジニアの先輩です。
今回は、TypeScriptの学習において一つの大きな山場であり、かつ最高の快感を味わえるテーマ「再帰型(Recursive Types)」についてお話しします。
ツリー構造のデータや、際限なく入れ子になる設定オブジェクトを関数に渡したいとき、「いったいどうやって型をつければいいんだろう?」と手が止まったことはありませんか?「`any`型で逃げちゃえ」と思ったあなた、ちょっと待ってくださいね。
ここをクリアすれば、TypeScriptの型システムが「ただの面倒なチェックツール」ではなく、「あなたの意図を完璧に守ってくれる最強の相棒」に変わる瞬間が訪れます。一緒にマスターしていきましょう!
—
1. なぜ「再帰型」が必要になるのか?
実務でよくあるのが、以下のような「階層構造(ツリー構造)」を持つデータを扱う場面です。
- 組織図(マネージャーの下にメンバーがいて、その下にまた…)
- ファイルシステム(フォルダの中にファイルやフォルダがあって…)
- UIコンポーネントのツリー(レイアウトの中にボタンや別レイアウトがあって…)
これらを引数にとる関数を想像してください。
「無限に続くかもしれない構造」に対して、私たちはどう型を定義すればいいのでしょうか?
直感的なアプローチ(そして挫折)
例えば、メニュー項目を表すオブジェクトを考えてみます。
// 頑張って2階層目まで書いた例
type MenuItem = {
label: string;
children?: {
label: string;
children?: {
label: string;
// …終わりが見えない!
};
};
};
これでは3階層目でエラーになってしまいますよね。
ここで登場するのが、自分自身の型を自分の中で参照する「再帰型(Recursive Types)」というテクニックです。
—
2. 基本的な書き方:自分自身を型に組み込む
百聞は一見に如かず。まずは、美しい再帰型のコードを見てみましょう。
/
- メニュー項目の型定義(再帰型)
/
type MenuItem = {
label: string;
// 子要素は、自分自身(MenuItem)の配列であってもよい!
children?: MenuItem[];
};
/
- 階層構造を持つメニューを再帰的に処理する関数
/
function renderMenu(item: MenuItem, depth: number = 0): void {
const indent = ‘ ‘.repeat(depth);
console.log(`${indent}- ${item.label}`);
// 子要素が存在する場合、それぞれの要素に対して自分自身を呼び出す
if (item.children) {
for (const child of item.children) {
renderMenu(child, depth + 1);
}
}
}
// — 実際に使ってみましょう —
const siteMenu: MenuItem = {
label: ‘ホーム’,
children: [
{ label: ‘プロフィール’ },
{
label: ‘ブログ’,
children: [
{ label: ‘TypeScript入門’ },
{ label: ‘React実践’ },
],
},
],
};
// 実行!
renderMenu(siteMenu);
このコードの意味と仕組み
`type MenuItem` の中にある `children?: MenuItem[];` に注目してください。
ここで、型定義が完成する前に `MenuItem` という名前をもう一度使っています。
TypeScriptのコンパイラは、この定義を見たときに「あ、`MenuItem` は自分の中に `MenuItem` の配列を持てるんだな」と、自己参照のループ構造を正しく理解してくれます。
関数側(`renderMenu`)でも、引数として受け取ったデータがどれだけ深く入れ子になっていても、型安全に `item.children` をスキャンできるようになります。これで「無限の深さ」に対応完了です!
—
3. 陥りやすい罠:型推論の無限地獄と「スタックオーバーフロー」
再帰型を使いこなせるようになると、少し複雑な「条件付き型(Conditional Types)」や「オブジェクトの深い部分をすべてオプショナルにするDeepPartial」といった高度な型を作りたくなります。
ここで、初心者が必ずと言っていいほどハマる「2つの罠」についてお伝えしておきますね。
罠1: 無限再帰による型の爆発(Type instantiation is excessively deep)
TypeScriptのコンパイラは、型を評価するときに無限のループに入らないよう、深さの制限を持っています。
// 危険な例:終了条件のない無限再帰型
type Infinite
このような型を作ってしまうと、コンパイラが「深すぎて計算できません!」と音を上げてコンパイルエラー(`Excessively deep`)を吐きます。
【解決のコツ】
再帰型を作るときは、必ず「これ以上再帰しなくてよい終端条件(プリミティブ型や `undefined` など)」を明示するようにしましょう。先ほどの `MenuItem` も、`children` が省略可能(`undefined` になり得る)であるため、そこで再帰がストップする安全な仕組みになっています。
罠2: JavaScriptの実行時スタックオーバーフロー
TypeScriptの「型」がコンパイル時にチェックを終えたとしても、実際のJavaScriptコードを実行するときに無限ループ(再帰関数のベースケースの書き忘れ)をしていれば、ブラウザやNode.jsがクラッシュします。
// 実行時エラーになる例(終了条件がない)
function badRecursive(item: MenuItem) {
// 終了条件(if (item.children) など)がないため、永遠に自分を呼び出し続ける
badRecursive(item.children![0]);
}
型が通ったからといって油断せず、「いつ再帰が止まるのか」という実行時のロジックも必ずセットで意識するようにしてくださいね。
—
4. 実戦で役立つ!JSONパーサ風の再帰型
最後に、もう少し実務的な例として、「JSONで表現できる任意のデータ構造」を受け取る関数の型を見てみましょう。JSONはオブジェクトの中に配列があり、その中にまたオブジェクトがある典型的な再帰構造です。
// JSONで扱える値の型(プリミティブ+オブジェクト+配列)
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
/
- 任意のJSON構造を安全にログ出力する関数
/
function printJson(data: JsonValue): void {
// ここで型に応じた安全な処理が書ける
console.log(JSON.stringify(data, null, 2));
}
// 複雑に入り組んだデータも、この通り完璧に型チェックされます
printJson({
id: 1,
name: “Yamada”,
skills: [“TypeScript”, “Node.js”],
metadata: {
verified: true,
history: [
{ year: 2020, role: “Junior” },
{ year: 2023, role: “Architect” }
]
}
});
この `JsonValue` 型は、TypeScriptの再帰型の美しさが最も活きる例の一つです。これさえ書ければ、複雑なAPIレスポンスの型定義も怖くありません。
—
まとめ
今回は関数における引数の階層的な型定義として、「再帰型(Recursive Types)」を解説しました。
- 再帰型とは:自分自身の型を内部で参照し、ツリー構造などの無限の階層を表現するテクニック。
- 書くときのコツ:必ず `undefined` やプリミティブ型などの「終了条件(ストッパー)」を設けること。
- 注意点:コンパイラの深さ制限(型の無限ループ)と、実行時の無限再帰に気をつけること。
ここをクリアできたあなたは、もうTypeScriptの初学者を卒業し、中級者への道を確実に歩んでいます。複雑なデータ構造に出会っても、恐れずに再帰型を思い浮かべてみてくださいね。
あなたのTypeScriptライフが、より型安全で素晴らしいものになりますように!質問があればいつでも先輩に聞いてくださいね。