【実務・中級編】 Conditional Typesの分配法則 – TypeScript実践ガイド

やあ、最近のコードベースの型定義、いい感じに複雑化してないかい?(笑)

TypeScriptを書き始めて数年、`string`や`number`といった基本のプリミティブから、ジェネリクスをバリバリ使いこなせるようになると、次にぶ

つかるのが「Conditional Types(条件型)」の壁だ。特に、今日話す「分配法則(Distributive Conditional Types)」は、多くのフロントエンドエンジニアを「あれ、なんでこうなるの?」と深夜のオフィスで頭を抱えさせる魔力を持っている。

今回は、ユニオン型が条件型に飲み込まれたときに裏側で何が起きているのか、そしてそれを華麗にコントロールするための「タプル化」という実務の現場でめちゃくちゃ使えるテクニックを、私の経験を交えて叩き込んでいこう。

—

1. 条件型の「分配法則」ってそもそも何者なんだ?

まずは基本のおさらいからいこう。TypeScriptの条件型は、ざっくり言うと三項演算子みたいなやつだ。

T extends U ? X : Y

「もし `T` が `U` に割り当て可能なら `X`、そうでなければ `Y`」というアレだね。ここまではいい。問題は、この `T` にユニオン型(例:`A | B`)をぶち込んだときだ。

TypeScriptのコンパイラは、`T` が「裸の型パラメータ(naked type parameter)」である場合、勝手に分配法則(Distribution)を適用する。つまり、こういうことだ:

(A | B) extends U ? X : Y

と書いたつもりが、コンパイラの脳内ではこう展開される。

(A extends U ? X : Y) | (B extends U ? X : Y)

ユニオンの要素一つひとつに条件型が「分配」されて、最後に再度ユニオンとして結合されるんだ。この挙動、便利なときは神がかって便利なんだけど、意図しないところで発動すると、型エラーのラビリンスに引きずり込まれる。

ブラウザやランタイムの裏側で何が起きているか?

ちょっと待って、「TypeScriptはコンパイル時に消えるんだから、ブラウザのJavaScriptの動作とは関係ないのでは?」と思ったそこの君、鋭いね。

確かにブラウザのV8エンジンやJavaScriptのランタイムは、型情報なんて綺麗さっぱり忘れてただのJSを実行している。しかし、TypeScriptの型チェッカー(TSServer)は、開発者のIDE(VSCodeなど)の裏側で、この分配法則の展開をものすごい勢いで計算しているんだ。

ユニオン型が巨大になると、この分配の組み合わせ爆発(Combinatorial explosion)が起き、VSCodeの補完が重くなったり、TypeScriptの型チェックが「Instantiation depth exceeded(型の深さ制限オーバー)」で力尽きたりする。ランタイムではなく、開発者の生産性とIDEのCPUをゴリゴリ削るのが、この分配法則の裏側のリアルな姿なのさ。

—

2. 現場で遭遇する「意図しない分配」の悲劇

百聞は一見に如かず。実務でよくある例を見てみよう。
例えば、APIから返ってくるレスポンスの型をいい感じにラップするユーティリティ型を作ろうとしたとする。

// 入力された型が string なら 「文字列です」、それ以外なら 「その他です」 と返す型を作りたい
type CheckString = T extends string ? “文字列です” : “その他です”;

// 単体なら期待通り
type Result1 = CheckString;
// 予想: string も number も混ざってるから “その他です” かな?
// 現実: “文字列です” | “その他です” (は……?)

そう、これが「勝手に分配された」瞬間だ。
`CheckString` は、内部で `CheckString | CheckString` に分解され、結果として `”文字列です” | “その他です”` という、求めてもいないユニオン型が爆誕してしまう。

「いや、私は `string | number` という塊全体を判定したかったんだよ!」というシーン、実務では本当によくある。この暴れ馬をどうやって手なずければいいのだろう?

—

3. 解決策:タプル化による「分配の抑制(Nakedの解除)」

ここで登場するのが、今日一番伝えたかったテクニック、「タプル化(Tuple wrapping)」だ。

やり方は拍子抜けするほどシンプル。条件型のチェック対象 `T` と、比較対象 `U` を、ひっそりと配列(タプル)で囲むだけ。たったこれだけ。

// 改善版:タプルで包み込んで分配を完全にブロックする
type CheckStringStrict = [T] extends [string] ? “文字列です” : “その他です”;

これだけで、何が起きるか?
`[T]` は、もはや「裸の型パラメータ(naked type parameter)」ではなくなる。TypeScriptのコンパイラは「お、これは配列同士の比較だな。分配法則はやめておこう」と判断し、ユニオン型をそのまま一つの塊として判定してくれるんだ。

先ほどの意図しない分配が起きていたコードで試してみよう。

type ResultStrict3 = CheckStringStrict;
// 結果: “その他です” (キターーー!これだよ、これが欲しかった!)

`string | number` は `string` 全体に割り当てられるわけではないので、見事に「その他です」に着地する。この「タプルで包んで分配を殺す」テクニックは、複雑な型ユーティリティを自作するときには必須の共通言語だ。

—

4. 実戦投入:コピペで使える「綺麗で実用的な型定義」

よし、理論はここまでにして、明日から君のプロジェクトの `types/` ディレクトリにそのままコピペして使える、実用的なユーティリティ型のコードを置いておくよ。

ここでは、「ユニオン型の中から特定の型だけをきれいに除外する(あるいは抽出する)が、分配の制御を意識した型」のモダンな書き方を見てみよう。

/

  • 【シニアのTips】
  • 標準の Exclude はすでに分配法則を利用していますが、
  • あえて「分配させたくないケース」や、自作の厳密な条件分岐を作る際のベースとなるパターンです。

/

// 1. タプルを使って分配を抑制する、安全な型チェックのベース
type IsExactType = [T] extends [U] ? ([U] extends [T] ? true : false) : false;

// 2. 実務でよく使う:null と undefined を絶対に許したくない厳密なOmit
// 標準の Omit だとユニオンが混ざったときに挙動が怪しくなるのを防ぐ決定版
export type StrictOmit = {
[P in keyof T as [P] extends [K] ? never : P]: T[P];
};

/

  • 使用例:APIレスポンスの型調整

/
type UserResponse = {
id: string;
name: string;
token: string;
age: number;
};

// ‘token’ と ‘age’ を安全に除外する
// もしここで K にユニオンや複雑な条件が絡んでも、タプル化の恩恵でバグりにくい
type PublicUser = StrictOmit;
// 結果: { id: string; name: string; }

この `[P] extends [K] ? never : P` の部分で、まさに今回解説したタプル化による分配の抑制が効いている。ここを裸の `P extends K` にすると、予期せぬユニオンの振る舞いに足元をすくわれることがあるんだ。

—

5. まとめ:プロのアーキテクトからのメッセージ

TypeScriptの型システムは、突き詰めると「論理パズル」であり、同時にコンパイラとの対話だ。
Conditional Typesの分配法則は、一見すると魔法のようであり、時にはバグの温床になる諸刃の剣。

  • 「あ、今ユニオンが勝手にバラされてるな?」と気づく嗅覚を持つこと
  • バラされたくないときは、`[T] extends [U]` のようにタプルで優しく包んであげること

この2つを頭の片隅に置いておくだけで、君が書く型定義のロバスト性(堅牢性)は跳ね上がるし、チームメンバーから「お、この型の設計、めちゃくちゃキレイだな……!」と一目置かれること間違いなしだ。

さあ、エディタを開いて、プロジェクトの複雑な型を綺麗にリファクタリングしに行こうぜ!

コメント

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