【実務・中級編】再帰的型定義(Recursive Types)でJSONツリー構造を型付けする – TypeScript コア・型システムの基礎解析バイブル

TypeScriptを掌握する極限の知見:再帰的型定義でJSONツリーを完全掌握する

コードレビューをしていて、最も頭痛がする瞬間の一つがこれだ。

`Record` または、思考停止で `any` が散りばめられた非構造化オブジェクトの山。
APIから返ってきたネストされたJSONをコンポーネントのプロパティにバリーッと流し込み、「なぜかプロパティが存在しないエラーで夜中にプロダクションが落ちた」というインシデント。それを「TypeScriptの型安全の限界ですね」などと言い訳するジュニアエンジニアを、私は何人も見てきた。

甘えるな。TypeScriptの型システムは、Turing完全だ。
適切に設計された再帰的型定義(Recursive Types)を用いれば、無限の深さを持つJSONツリー構造であっても、コンパイル時にその構造を完全に暴き、IDEの補完を極限まで効かせることができる。

今回は、実務のフロントエンド開発やAPI連携において、一歩も妥協しない「バグの起きない堅牢なJSONツリーの型設計」を、コンパイラの挙動を見据えながら伝授する。

—

なぜ「普通の型定義」ではJSONツリーを表現できないのか?

まず、私たちが扱うJSONのプリミティブとコンテナの性質を思い出してほしい。
JSONの本質は、スカラー値(`string`, `number`, `boolean`, `null`)と、それらを包含する再帰的なコンテナ(`Array`, `Object`)の組み合わせに他ならない。

これを愚直に書こうとすると、以下の壁にぶつかる。

// ❌ どこかで見たような、深さが固定された絶望的なコード
type JSONValue = string | number | boolean | null | JSONObject;
type JSONObject = {
[key: string]: string | number | boolean | null | {
[key: string]: string | number | boolean | null | {
// 無限にネストさせたいが、終わりが見えない…
}
}
};

これでは有限の深さで型が破綻する。型エイリアス(`type`)の本質は、自身を指し示すポインタを遅延評価させることにある。
TypeScript 4.1以降で導入されたテンプレートリテラル型や条件付き型(Conditional Types)、そして自己参照型エイリアスの組み合わせにより、この問題は美しく解決できる。

—

プロダクション品質:完全なJSONツリー型定義

実務でそのままコピー&ペーストして使える、極限まで最適化されたJSONツリーの型定義を見てほしい。単にデータを表すだけでなく、「型安全なパス抽出」や「ミュータブル/イミュータブルの制御」まで考慮した実装だ。

/

  • 厳密なJSONプリミティブ型

/
export type JsonPrimitive = string | number | boolean | null;

/

  • 再帰的なJSON配列型

/
export type JsonArray = JsonValue[];

/

  • 再帰的なJSONオブジェクト型(イミュータブル設計)

/
export type JsonObject = {
readonly [key: string]: JsonValue;
};

/

  • 究極のJSONツリー型
  • コンパイラが自己参照を正しく解決できる最適なユニオン構造

/
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;

これだけでは「入門編」の域を出ない。
ここからがチーフアーキテクトの腕の見せ所だ。実務では、「この深くネストしたJSONから、特定のキーの値を取り出す関数」や「ドット区切りのパス(例: `user.address.zipCode`)で型安全にアクセスしたい」という要件が必ず発生する。

次章では、再帰的型定義を応用した「パス型推論エンジン」を構築する。

—

実践:ドット区切りパスの型安全な推論(Deep Get)

「APIから返ってきたオブジェクトのどこに何があるか分からないから、オプショナルチェーン(`?.`)を乱れ撃ちする」
そんなフロントエンドコードは今日で卒業にしよう。

JSONツリーの型から、コンパイル時に有効なパス文字列をすべて自動生成し、さらにそのパスに対応する値の型を逆引きする魔術的な型ユーティリティを実装する。

/

  • オブジェクトのキーがネストしている場合、ドット区切りのパス型を再帰的に生成する
  • 例: “user” | “user.profile” | “user.profile.name”

/
export type DeepPath = T extends object
? {
[K in keyof T]: K extends string | number
/

  • 配列の場合はインデックスもパスに含めるか、オブジェクトのプロパティとして展開する

/
? T[K] extends readonly any[]
? `${K}` | `${K}.${number}` | `${K}.${number}.${DeepPath}`
: `${K}` | `${K}.${DeepPath}`
: never;
}[keyof T]
: never;

/

  • ドット区切りのパス文字列をもとに、JSONツリーから値の型を正確に引き抜く

/
export type DeepValue = P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepValue
: T extends readonly any[]
? number extends keyof T
? DeepValue
: never
: never
: P extends keyof T
? T[P]
: T extends readonly any[]
? number extends keyof T
? DeepValue
: never
: never;

このコードがもたらす圧倒的な開発体験

上記の型ユーティリティを、実際の取得関数(`getValueByPath`)に組み込んでみよう。

// 実際のAPIレスポンスを模したJSONツリー
type ApiResponse = {
status: number;
data: {
user: {
id: string;
roles: string[];
profile: {
firstName: string;
lastName: string;
};
};
};
};

/

  • 型安全なパス指定型ゲッター
  • 存在しないパスを渡した場合はコンパイルエラーになる

/
function getJsonValue>(
obj: T,
path: P
): DeepValue {
const keys = path.split(‘.’);
let current: any = obj;
for (const key of keys) {
current = current[key];
}
return current;
}

// — 使用例 —
const response: ApiResponse = {
status: 200,
data: {
user: {
id: “usr_123”,
roles: [“admin”, “editor”],
profile: {
firstName: “Taro”,
lastName: “Yamada”,
},
},
},
};

// 🟢 成功: 戻り値の型は `string` として完璧に推論される
const firstName = getJsonValue(response, “data.user.profile.firstName”);

// 🟢 成功: 配列のインデックスアクセスも型安全
const firstRole = getJsonValue(response, “data.user.roles.0”);

// 🔴 コンパイルエラー: “data.user.profile.middleName” なんてキーは存在しない
// const invalid = getJsonValue(response, “data.user.profile.middleName”);

どうだろうか。開発者はIDEの補完(IntelliSense)の恩恵を100%受け、タイポによるバグはコンパイル段階で完全にゼロに駆逐される。

—

パフォーマンス上の注意点:型評価の「発散」を防ぐ極意

ここで、シニアエンジニアとして読者に最も伝えなければならない「型エンジニアリングの暗黒面」がある。
それは、「再帰的型の無限発散(Infinite Recursion / Type Instantiation is excessively deep)」だ。

TypeScriptのコンパイラ(tsc)は、型を評価する際に再帰の深さに上限(通常は50階層程度)を設けている。
構造が複雑すぎるJSON、あるいは循環参照(Circular Reference)を含むJSONを素朴に再帰型で処理すると、以下のエラーに直面する。

> `Type instantiation is excessively deep and possibly infinite. (2589)`

対策:再帰の深さを制限する(Tail-Recursion / Depth Guard)

プロダクションコードでは、万が一の過剰なネストや循環参照によるコンパイラクラッシュを防ぐため、「再帰の深さカウンター」を挟むのがプロの作法だ。

// 再帰の深さを制御するためのカウンタ配列
type PrevAction = [never, 0, 1, 2, 3, 4, 5]; // 最大深度を5階層に制限する場合

type SafeDeepPath = PrevAction[Depth] extends never
? never // 深度制限を超えたら打ち切る
: T extends object
? {
[K in keyof T]: K extends string | number
? `${K}` | `${K}.${SafeDeepPath}`
: never;
}[keyof T]
: never;

大規模なモノリスアプリケーションや、サードパーティの巨大なスキーマを扱う場合、この「深さのガード」を組み込んでいるかどうかが、ビルドサーバーの安定性を分ける決定的な境界線となる。

—

チーフアーキテクトからの総括

再帰的型定義は、単なる「お洒落なTypeScriptのテクニック」ではない。
それは、「実行時まで分からない不確実なデータ構造(JSON)を、静的なコンパイルの網の目にかけ、完全に制御下へ置くための強力な武器」である。

フロントエンドのコードベースから `any` を一掃し、APIの変更にビクともしない堅牢なアーキテクチャを築き上げること。それこそが、プロダクトの寿命を延ばし、エンジニアの精神的健康を守る唯一の道だ。

今日からあなたのプロジェクトでも、思考停止した `Record` を捨て、この再帰的型定義を導入してほしい。コードレビューで私があなたのコードを見る日を楽しみにしている。

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