【実務・中級編】 再帰的なConditional Types – TypeScript実践ガイド

フロントエンドの現場で頭を悩ませる瞬間の一つに、「APIから返ってくる、何層にネストしているか分からない深淵のようなJSONの型定義」がありますよね。

「親の顔より見た `Partial` を使ったのに、一段下のオブジェクトがオプショナルになってなくて盛大に型エラーが爆発した……」

あなたも、一度や二度、いや毎日のようにそんな絶望を味わっているはずです。普通の `Partial` は、残念ながら表面の皮一枚しか剥いてくれません。ネストの奥深くまでは届かない、いわば「表層的な優しさ」しか持っていないのです。

そこで登場するのが、今回解説する「再帰的なConditional Types(条件付き型)」です。型定義の中で自分自身を再帰的に呼び出すことで、果てしなく深いネストの構造体をもれなく平らにならしたり、すべてを `readonly` にしたりといった、一歩進んだ型パズルが可能になります。

今日は、この「再帰型」の魔力を解き明かし、明日からチームの誰もが「おっ、やるな」と唸るような実務コードを一緒に書いていきましょう。

—

1. なぜ通常のユーティリティ型では物足りないのか?

TypeScriptが提供する標準のユーティリティ型(`Partial`, `Required`, `Readonly` など)は、非常に優秀です。しかし、これらは基本的に「1階層目」のプロパティを対象に作られています。

例えば、次のようなユーザー情報の型があるとします。

type User = {
id: string;
profile: {
name: string;
address: {
city: string;
zipCode: string;
};
};
};

ここで、フォームの入力途中の一時保存用として、すべてのプロパティをオプショナル(省略可能)にしたいと考えました。素朴に標準の `Partial` を使うと、こうなります。

const draftUser: Partial = {
// id はオプショナルになったのでOK
profile: {
// あれ? ここでエラーが出る!
// ‘address’ プロパティがないと言われる
name: “Taro”,
},
};

なぜエラーになるのか? `Partial` は `profile` 自体をオプショナルにはしてくれますが、`profile` の中身(`name` や `address`)までは再帰的に追いかけてオプショナルにしてくれないからです。`profile` を書くなら、その中身は完全な形(REQUIRED)で要求されてしまうのです。

このもどかしさを解決するのが、再帰的なConditional Typesです。

—

2. ブラウザの裏側でTypeScriptはどう動いているのか?

少しだけアーキテクチャの話をさせてください。「型はビルド時に消えるから、ランタイムには関係ない」……それはその通りですが、TypeScriptのコンパイラ(型チェッカー)の頭の中では、型推論と評価が泥臭く行われています。

再帰的な型を定義すると、TypeScriptのパーサーとチェッカーは次のようなアルゴリズムを回します。

1. Conditional Typesの評価: `T extends U ? X : Y` の構文に遭遇すると、型 `T` が条件を満たすかを判定します。
2. 自己参照(Self-reference)の検出: 型の内部で自分自身の名前を呼び出している場合、コンパイラはそれを「展開すべき未完了のプロセス」としてスタックに積み上げます。
3. 型の展開(Instantiation): ネストの階層分だけ、ジェネリック型を再帰的に解決していきます。

ここで注意しなければならないのが、「無限ループ(Instantiation depth exceeded)」の恐怖です。JavaScriptで再帰関数を書くときに終了条件(Base Case)を忘れると `Maximum call stack size exceeded` でアプリがクラッシュするのと同じように、TypeScriptの型チェッカーも無限に自分を呼び出し続けると、無慈悲にエラーを吐いて沈黙します。

だからこそ、再帰型を書くときは「いつ再帰を止めるのか(プリミティブ型や配列、関数型に到達したらどうするか)」というガード条件が命綱になるのです。

—

3. 実践!現場で使える「DeepPartial」の自作と解説

百聞は一見に如かず。先ほどのフラストレーションを完全に解消する `DeepPartial` を、一緒に実装してみましょう。

そのままエディタにコピーして、Hoverして挙動を確認してみてください。

/

  • オブジェクトのネスト構造を再帰的にたどり、
  • すべての階層のプロパティをオプショナル(?)にする型

/
export type DeepPartial =
// 1. もしTがプリミティブ型(関数や配列、通常のオブジェクト以外)なら、そのまま返す(再帰の終了条件)
T extends Function ? T :
T extends Array ? Array> :
T extends object ? {
// 2. オブジェクトの場合は、すべてのキーを走査し、値に対して自分自身(DeepPartial)を再帰適用する
[K in keyof T]?: DeepPartial;
} :
// 3. どちらでもなければそのまま
T;

このコードの泥臭いポイント解説

  • `T extends Function` / `Array` のガード:

JavaScriptの世界では、配列も関数も突き詰めれば `object` の一種です。もしこれを考慮せずに単純に `T extends object` だけ判定してしまうと、配列の要素や関数の中身までバラバラに壊してしまいます。そのため、配列や関数は特別扱いして早期に弾く(あるいは適切に再帰させる)のがプロの技です。

  • `[K in keyof T]?` のクエスチョン:

ここで各プロパティに `?` を付与しているため、1階層目だけでなく、マップされたすべての階層のプロパティがもれなくオプショナルになります。

これを使えば、先ほどの複雑な `User` 型もこのようにスマートに扱えます。

const safeDraftUser: DeepPartial = {
// profileも、その中のaddressも、すべてオプショナルになりエラーが出ない!
profile: {
name: “Jiro”,
},
};

—

4. さらに応用:ネストされたオブジェクトをすべて `Readonly` にする `DeepReadonly`

実務では「絶対に書き換えられたくないイミュータブルな設定値」を扱うことも多いはずです。Reduxのステートや、APIのレスポンスキャッシュなどが代表例ですね。

もちろん、これも再帰型で一網打尽にできます。

/

  • ネストされたオブジェクトのすべてのプロパティを再帰的に読み取り専用(readonly)にする

/
export type DeepReadonly =
T extends Function ? T :
T extends Map ? ReadonlyMap, DeepReadonly> :
T extends Set ? ReadonlySet> :
T extends Array ? ReadonlyArray> :
T extends object ? {
readonly [K in keyof T]: DeepReadonly;
} :
T;

ここまで書けると、`Map` や `Set` といったビルトインのコレクション型まで考慮できるようになり、シニアエンジニアとしての確かな手応えを感じられるはずです。

—

5. シニアからのアドバイス:再帰型を使うときの心構え

再帰的なConditional Typesは、フロントエンドの型設計を次のレベルに引き上げてくれる強力な武器です。しかし、力には常に責任が伴います。最後に、実務で使う上での注意点をいくつか共有しておきます。

1. コンパイル速度(IDEのパフォーマンス)への影響
あまりにも複雑怪奇な再帰型を巨大なユニオン型に対して適用すると、VS Codeのインテリセンス(推論表示)が重くなったり、CIでの型チェック(`tsc`)が露骨に遅くなったりします。「動くからいいや」ではなく、チームの開発体験を損ねていないか常に意識してください。
2. まずは標準のユーティリティ型で足りないか疑う
なんでもかんでも `Deep` 系を作ればいいというわけではありません。多くの場合、APIの設計やコンポーネントの分割を見直すことで、浅い型定義でも十分に破綻を防げます。「複雑な型を書くこと」が目的にならないようにしましょう。

型定義は、コードを書くあなたと、未来のチームメイト、そして何よりTypeScriptのコンパイラへのラブレターです。泥臭く、しかし美しく型を操り、保守性の高い堅牢なフロントエンドアーキテクチャを一緒に築き上げていきましょう!

コメント

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