【実務・中級編】 Distributive Conditional Typesの挙動 – TypeScript実践ガイド

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 = T extends U ? never : T;

たったこれだけだ。なぜこれで除外できるのか? 実際に動かしてみよう。

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 = T extends string ? true : false;

// 期待値: 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 = [T] extends [string] ? true : false;

// 正しく 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` を包んで裸の状態を回避する。

型定義は単なるお絵描きではなく、コードの安全性を担保する堅牢なアーキテクチャだ。仕組みをハッキリと理解して、明日からのコードをより美しく、より頑丈に仕上げていってほしい。それじゃあ、また現場で会おう。

コメント

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