【テクニカル・上級編】 Conditional Typesの分配法則 – TypeScript実践ガイド

こんにちは。フロントエンドのアーキテクチャの荒波を、厳格な型安全という羅針盤だけで生き抜いてきた君なら、一度は目にしたことがあるはずだ。「あれ、なんでユニオン型を渡しただけで、結果が勝手にバラバラに展開されて合併(Union)されちまうんだ?」というあの奇妙な現象を。

そう、今日のテーマは Conditional Types(条件型)の分配法則(Distributive Conditional Types) だ。

公式ドキュメントをサラッと斜め読みしただけでは見落としがちだが、この「勝手に分配される仕様」こそが、実務レベルの複雑な型パズルを解くときに最高の武器にもなれば、一歩間違えればコンパイラを暴走させ、IDEのメモリを食い潰す凶器にも化ける。

今回は、V8エンジンやTypeScriptの型チェッカー(TSServer)の内部挙動に思いを馳せながら、この分配法則の本質と、それを意図的にコントロールする「タプル化による抑制テクニック」について、現場の泥臭い知見を交えて徹底的に解説しよう。

—

なぜTypeScriptは勝手に配りたがるのか?(分配法則のメカニズム)

まずは基本のおさらいだ。TypeScriptで以下のような条件型を書いたとする。

// T が string なら A を、そうでなければ B を返す単純な条件型
type IsString = T extends string ? A : B;

type A = string;
type B = number;

ここに、単一の型ではなく `string | number` というユニオン型を突っ込んでみるとどうなるだろうか?

type Result = IsString;
// 結果は A | B になる

「おっと、待ってくれ」と。`string | number` 全体を一つの塊として `T` に代入し、「お前、全体として `string` か?」と判定してほしいだけなのに、TypeScriptのコンパイラはこれを勝手に分解し、以下のように個別判定してから最後にユニオンで結合しやがる。

1. `IsString` 評価 $\rightarrow$ `A`
2. `IsString` 評価 $\rightarrow$ `B`
3. 最終結果: `A | B`

これが Distributive Conditional Types(分配的条件型) だ。

TypeScriptの設計思想として、ユニオン型は「ドメインの取りうる値の集合」を表現する。この集合の要素(メンバー)ごとに条件分岐を適用したいケース(例えば、不要な型をフィルタリングするユーティリティなど)では、この挙動は極めて合理的で強力だ。実際、組み込みの `Exclude` や `Extract` は、この分配法則の恩恵をフルに受けて実装されている。

しかし、大規模なWebアプリケーションのステート管理や、複雑なAPIレスポンスの正規化レイヤーを設計しているとき、「ユニオンをバラされたくない(一つの集合として評価してほしい)」 という場面に必ず直面する。

—

分配法則が引き起こす「実務での大チョンボ」

では、この分配法則が引き起こす具体的な悪夢を見てみよう。例えば、ある関数の引数やコンポーネントのPropsとして、「特定のオブジェクトの配列、または単一のオブジェクト」を受け取る汎用的な型を作りたいとする。

ここで、誤って以下のような条件型を組んでしまったとしよう。

// 意図:Tが配列ならそのまま、単一体なら配列にして返したい(つもり)
type EnsureArray = T extends any[] ? T : T[];

// ここでユニオン型をぶち込んでみる
type UserInput = string | number;
type Processed = EnsureArray;
// 期待値: (string | number)[] (文字列か数値の入った配列)
// 実際の値: string[] | number[] (文字列の配列、または数値の配列のユニオン)

見てくれ、この違いを。
`string[] | number[]` は、「文字列の配列」または「数値の配列」のどちらかであって、「1つの配列の中に文字列と数値が混ざっていてもいい」という意味にはならない。

もしこの型をフォームのバリデーションロジックや、Zodなどのスキーマ推論の基盤に組み込んでいたらどうなるか?
「あれ? 文字列の配列を渡したはずなのに、なぜか数値の配列として型エラーになるぞ……?」という、原因究明に数時間をドブに捨てる不毛なデバッグタイムの開幕だ。TSServerのメモリ消費量は跳ね上がり、エディタの補完は重くなり、君の精神衛生は削られていく。

—

救世主:タプル化による分配の抑制(Distributive Inhibition)

では、この余計なお節介(分配法則)をいかにして防ぐのか?
TypeScriptの型システムには、分配を発動させないための極めてシンプルかつエレガントな抜け道が用意されている。それが 「タプルでラップする」 というテクニックだ。

百聞は一見にしかず。コードを見てほしい。

// T を一度タプル(配列型)で包み、extends の左辺もタプルにする
type EnsureArraySafe = [T] extends [any[]] ? T : T[];

type UserInput = string | number;

// 今度はどうなる?
type StrictlyProcessed = EnsureArraySafe;
// 結果: (string | number)[] (見事に意図したユニオン配列になった!)

なぜこれだけで分配が止まるのか?

TypeScriptの仕様として、「裸の型パラメータ(naked type parameter)」 が条件型の左辺(`T extends …` の `T` の部分)にあるときだけ、分配法則がトリガーされる。

つまり、`T` がそのまま裸で置かれていると分配されるが、`[T]` や `{ type: T }` のように何らかの型コンテナに包まれてしまうと、それは「裸の型パラメータ」ではなくなる。そのため、TypeScriptはそれを一つの「固まり(タプル)」として扱い、ユニオンをバラバラに展開するのをやめるのだ。

右辺側も `[any[]]` とタプルに合わせているのは、比較する型の構造を一致させるためである。

この「タプル化(`[T] extends […]`)」は、高度なTypeScriptアーキテクチャを構築する上では必須のイディオムだ。特に、ジェネリックなユーティリティ型を作る際、入力がユニオン型として渡される可能性があるなら、意図せず分配が起きないか常に疑う必要がある。

—

アーキテクチャ視点:パフォーマンスと型安全性のトレードオフ

ここで、パフォーマンスやレンダリング負荷、そしてコンパイル速度の観点からも少し踏み込んでおこう。

巨大なコードベース(例えば、数万行規模のReactアプリケーションや、複雑なGraphQL/RESTの型定義を持つモノレポ)において、無秩序な条件型の乱用はコンパイル速度(TSServerのパフォーマンス)を確実に悪化させる。

1. 分配法則による型の爆発(Type Explosion)
ユニオンの要素数が $N$ 個のとき、分配的条件型がネストしていると、評価される型の組み合わせが幾何級数的に増加する。これがいわゆる「型の爆発」であり、CIのビルド時間が延びる主原因の一つだ。
2. タプル化による最適化
あらかじめ `[T] extends […]` で分配を抑制し、ユニオンを1つの評価単位として処理させることは、コンパイラの評価ステップ数を削減し、メモリ効率を改善する上で非常に有効なアプローチとなる。

「型が複雑になればなるほど、コンパイラの負荷も上がる」。
フロントエンド・アーキテクトとして、私たちはただ動くだけの型を書くのではなく、「コンパイルエンジンに優しい型」 を設計する責任があるのだ。

—

実践:条件型と分配抑制を組み合わせた実用パターン

最後に、実際の現場で即戦力となる、条件型の分配制御を活用したユーティリティ型の例を残しておこう。与えられた型が「ユニオン型そのものかどうか」を判定する、少しマニアックだが実用的な型だ。

// T がユニオン型であるかを判定する高度な型
// (非ユニオンなら false、ユニオンなら true を返す)
type IsUnion =
// ここで分配法則をあえて利用する!
// T がユニオンなら、U は固定のまま、T だけが分配されて各要素になる
T extends any
// 分配された個々の T と、全体のユニオン U を比較する
// ユニオンであれば、個々の T は U 全体に代入できない([U] extends [T] が false になる)
? [U] extends [T]
? false
: true
: never;

// — 動作確認 —
type Test1 = IsUnion; // false (単一型)
type Test2 = IsUnion; // true (ユニオン型)
type Test3 = IsUnion; // true (ユニオン型)

この型は、あえて「分配法則を爆発させる側(`T extends any`)」と、「タプル化で分配を抑制する側(`[U] extends [T]`)」を巧みに組み合わせることで実現している。
こうしたテクニックを自在に操れるようになると、APIクライアントの型推論や、複雑なフォームライブラリの型設計において、TypeScriptの表現力の限界を軽々と突破できるようになる。

—

まとめ

  • 分配法則の本質: ユニオン型を裸の条件型(`T extends …`)に渡すと、要素ごとにバラされて個別に評価・再結合される。
  • リスク: 意図しない `string[] | number[]` のような型の乖離を生み、バグやコンパイル速度の低下を招く。
  • 解決策(タプル化): `[T] extends [U]` のようにタプルでラップすることで、分配を抑制し、ユニオンを1つの塊として安全に評価できる。

型システムは、単なるバグ防止のガードレールではない。それは、君が描いたアーキテクチャの意図をコンパイラに正確に伝え、IDEの補完を通じて未来の自分やチームメンバーと対話するための、最も美しいコードだ。

さあ、エディタを開いて、君のプロジェクトにある野放しになった条件型たちを今すぐ見直してみようじゃないか。

コメント

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