TypeScriptで型をゴリゴリ書き始めると、必ずと言っていいほどぶつかる壁がある。「あれ、なんか思ってた挙動と違うぞ?」という瞬間だ。
特に、ユニオン型(`A | B`)を条件付き型(Conditional Types:`T extends U ? X : Y`)に放り込んだとき、コンパイラが勝手に気を利かせて(あるいは余計なお世話で)型をバラバラにして処理する挙動に、最初は頭を抱えたはずだ。
おいおい、勝手に分散するなよ!と叫びたくなるその仕様こそが、今回深掘りする「Distributive Conditional Types(分配条件型)」だ。
この仕様を完全に理解すると、標準ライブラリの `Exclude` や `Extract` が中でどうやって魔法を使っているのかが手に取るように分かり、型パズルで無駄に悩む時間が劇的に減る。さあ、シニアの俺が、現場のリアルな視点も含めてこの「分配の法則」の裏側を解き明かしてやろう。
—
1. そもそも「分配法則」って何が起きているのか?
TypeScriptにおける条件付き型は、基本の形としてこう書く。
T extends U ? X : Y
ここで、型引数 `T` にユニオン型(例: `’a’ | ‘b’ | ‘c’`)を渡したとき、「何もしなくても、勝手にユニオンのメンバーが1つずつ取り出されて条件判定にかけられる」という仕様が発動する。これがDistributive Conditional Typesだ。
数学の分配法則 $X \times (A + B) = (X \times A) + (X \times B)$ を思い出してほしい。あれと同じことが、型レベルで行われている。
ブラウザ(tsc)の裏側で何が起きているのか?
TypeScriptのコンパイラ(`tsc`)は、型チェックの際、条件付き型の左辺(`T`)が「裸の型パラメータ(naked type parameter)」であるかどうかをまず確認する。
もし `T` が何にも包まれていない「裸」の状態でユニオンを受け取ると、コンパイラは内部的に次のようなループ処理(イメージ)を走らせる。
// ユニオン型 (‘a’ | ‘b’ | ‘c’) を渡したときのコンパイラの脳内処理
type Result =
| (‘a’ extends U ? X : Y)
| (‘b’ extends U ? X : Y)
| (‘c’ extends U ? X : Y);
最後に、バラバラに処理されたそれぞれの結果を、再びユニオン(`|`)で結合する。これが分配法則の正体だ。
—
2. 実務でよく使う! `Exclude` と `Extract` の実装原理
この分配法則が最も美しく、かつ実用的に使われているのが、TypeScriptの標準ライブラリにある `Exclude` や `Extract` だ。これらの実装を見てみよう。実は、驚くほどシンプルに書かれている。
`Exclude` の実装
`Exclude` は、「型 `T` から型 `U` に割り当て可能なものを除外する」ユーティリティ型だ。
type MyExclude
たったこれだけだ。なぜこれで除外できるのか? 実際に動かしてみよう。
type T0 = MyExclude<'a' | 'b' | 'c', 'a'>;
コンパイラの脳内(分配法則の適用):
1. `’a’ extends ‘a’ ? never : ‘a’` ➔ `true` なので `never`
2. `’b’ extends ‘a’ ? never : ‘b’` ➔ `false` なので `’b’`
3. `’c’ extends ‘a’ ? never : ‘c’` ➔ `false` なので `’c’`
これらをユニオンで結合すると:
`never | ‘b’ | ‘c’` となる。
ここでTypeScriptの強力な特性思い出してほしい。ユニオン型に含まれる `never` は自動的に消滅する(単位元のようなものだ)。したがって、最終的な型は `’b’ | ‘c’` になる。うーん、実にエレガントだね。
—
3. 現場でハマりがち。「分散させたくない」ときの回避テクニック
さて、ここからが現場のエンジニアとしての腕の見せ所だ。
この「勝手に分配される仕様」、非常に便利な一方で、「いや、今回はユニオンをバラバラにしないで、ユニオン型そのものを `U` と比較したいんだよ!」というケースが実務では頻発する。
例えば、受け取った型 `T` が「ユニオン型そのもの」かどうかを判定したいときだ。
駄目な例:そのまま書くと勝手に分配される
// ユニオン型そのものが ‘string’ かどうかを判定したいつもり……
type IsStringUnion
// 期待値: false ( ‘a’ | 1 は string ではないから)
// 実際の挙動: ‘a’ は true、 1 は false になり、結果は boolean (true | false) になる!
type Result1 = IsStringUnion<'a' | 1>;
見事にやられたな。`T` が裸の型パラメータであるため、`’a’ extends string` と `1 extends string` に勝手にバラされてしまい、結果が `boolean`(`true | false`)になってしまった。
解決策:タプルで包んで「裸」を剥ぎ取る
この分配を防ぐための、実務で使える定石がこれだ。型をタプル(配列)で包む。
// タプルで包むことで、Tが「裸の型パラメータ」ではなくなる
type IsStringUnion_Fixed
// 正しく false になる!
type Result2 = IsStringUnion_Fixed<'a' | 1>;
// string のユニオンならちゃんと true になる
type Result3 = IsStringUnion_Fixed<'a' | 'b'>; // true
なぜこれで分配が止まるのか?
`[T]` とタプルにすることで、`T` はもはや単体の型パラメータではなくなり、`[ ‘a’ | 1 ]` という1つの配列型(タプル型)として扱われる。コンパイラは「お、これは配列同士の比較だな。ループ(分配)はやめておこう」と判断するわけだ。
実務で複雑なジェネリクスを組む際、「あれ、なんか勝手にユニオンが展開されて型がバグるぞ」と思ったら、大体この `[T] extends [U]` テクニックで解決できる。覚えておいて損はない。
—
4. 今日のまとめとシニアからのアドバイス
TypeScriptのDistributive Conditional Typesは、最初は魔術のように感じるかもしれない。だが、裏でコンパイラが「ユニオンをバラして1つずつループさせている」というイメージさえ持てれば、恐るに足りない。
- 分配させたいとき(標準の挙動):
`T extends U ? X : Y` のように、`T` を裸のまま置いておく。`Exclude` や `Extract` のような要素ごとのフィルタリングに最適。
- 分配させたくないとき(ユニオン全体を評価したいとき):
`[T] extends [U] ? X : Y` のように、タプルなどで `T` を包んで裸の状態を回避する。
型定義は単なるお絵描きではなく、コードの安全性を担保する堅牢なアーキテクチャだ。仕組みをハッキリと理解して、明日からのコードをより美しく、より頑丈に仕上げていってほしい。それじゃあ、また現場で会おう。

コメント