control-union-distribution について
条件型とユニオン型を扱う TypeScript 開発者向けのスキルです。条件型が分配される仕組みと、分配の抑制・強制方法を説明し、boolean や never で予想外の結果になる理由も扱います。
主な機能
- 01条件型の分配動作を解説
- 02単要素タプルで分配を抑制
- 03裸の条件で分配を強制
- 04boolean と never の特殊ケース
- 05ユニオン入力でのテスト方法
活用シーン
- 01条件型の予想外の結果の調査
- 02型定義でのユニオン動作の制御
- 03再帰的なジェネリック型のデバッグ
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill control-union-distributionインストール後、Claude Code で/control-union-distributionを入力して読み込まれたか確認します。
詳細
条件型におけるユニオンの分配を制御する
概要
TypeScript の条件型は、デフォルトでユニオンに対して分配されます。通常はこれが望ましい動作ですが、ときには予想外の挙動を引き起こします。分配を制御する方法を理解することは、望ましくないときに分配を防ぎ、必要なときに有効にするうえで、高度な型レベルプログラミングに欠かせません。
意外な挙動として、boolean が true | false として扱われること、never が分配されて never になること、そして再帰型が分配されないことが挙げられます。このスキルでは、こうしたケースへの対処方法を解説します。
このスキルを使う場面
- 条件型がユニオン入力に対して予想外の動作をする
boolean型が予想外の結果を返すnever型が予想外に評価される- ユニオンに対する分配を防ぐ必要がある
- 再帰的なジェネリック型が正しく分配されない
絶対ルール
条件を 1 要素のタプル [T] で囲むと分配を防げます。裸の条件 N extends... を追加すると分配を強制できます。分配条件型での boolean と never の挙動を理解しましょう。
検出
次のような予想外の挙動に注意してください。
// Surprising: boolean distributes
type Celebrate<V> = V extends true ? 'Huzzah!' : never;
type Surprise = Celebrate<boolean>; // "Huzzah!" not never
// Surprising: never distributes to never
type AllowIn<T> = T extends { password: string } ? 'Yes' : 'No';
type N = AllowIn<never>; // never, not 'Yes' | 'No'
// Problem: recursive type doesn't distribute
type NTuple<T, N> = /* ... */; // NTuple<string, 2 | 3> gives wrong result
分配を防ぐ
条件を 1 要素のタプル [T] で囲みます。
// Problem: distributes over unions
type Comparable<T> =
T extends Date ? Date | number :
T extends number ? number :
T extends string ? string :
never;
// Date | string becomes (Date | number) | string - wrong!
let dateOrStr: Date | string;
const result: Comparable<typeof dateOrStr>; // Should be never
// Solution: wrap in one-tuple
type Comparable<T> =
[T] extends [Date] ? Date | number :
[T] extends [number] ? number :
[T] extends [string] ? string :
never;
// Now Date | string correctly evaluates to never
Boolean の意外な挙動
TypeScript では boolean は true | false として扱われます。
type CelebrateIfTrue<V> = V extends true ? 'Huzzah!' : never;
// Surprising result
type Party = CelebrateIfTrue<true>; // "Huzzah!"
type NoParty = CelebrateIfTrue<false>; // never
type Surprise = CelebrateIfTrue<boolean>; // "Huzzah!" (!)
// Why? boolean distributes:
// CelebrateIfTrue<true | false>
// = CelebrateIfTrue<true> | CelebrateIfTrue<false>
// = "Huzzah!" | never
// = "Huzzah!"
// Fix: prevent distribution
type CelebrateIfTrue<V> = [V] extends [true] ? 'Huzzah!' : never;
type SurpriseFixed = CelebrateIfTrue<boolean>; // never - correct!
Never の意外な挙動
never は空のユニオンとして扱われます。
type AllowIn<T> = T extends { password: string } ? 'Yes' : 'No';
// Surprising: never evaluates to never
type N = AllowIn<never>; // never (not 'Yes' or 'No')
// Why? never is empty union:
// AllowIn<never> = AllowIn<> = empty union = never
// Fix: wrap in one-tuple
type AllowIn<T> = [T] extends [{ password: string }] ? 'Yes' : 'No';
type NFixed = AllowIn<never>; // 'No' - correct!
分配を有効にする
分配を強制する必要がある場合もあります。裸の条件を追加します。
// Problem: recursive type doesn't distribute
type NTuple<T, N extends number> = NTupleHelp<T, N, []>;
type NTupleHelp<T, N, Acc extends T[]> =
Acc['length'] extends N
? Acc
: NTupleHelp<T, N, [T, ...Acc]>;
type PairOrTriple = NTuple<string, 2 | 3>;
// Got: [string, string] (wrong!)
// Want: [string, string] | [string, string, string]
// Solution: add distributive wrapper
type NTuple<T, N extends number> =
N extends number // Forces distribution
? NTupleHelp<T, N, []>
: never;
type PairOrTripleFixed = NTuple<string, 2 | 3>;
// Now: [string, string] | [string, string, string] - correct!
完全な例
// Type-safe comparison function
type Comparable<T> =
[T] extends [Date] ? Date | number : // Prevent distribution
[T] extends [number] ? number :
[T] extends [string] ? string :
never;
declare function isLessThan<T>(a: T, b: Comparable<T>): boolean;
// Valid comparisons
isLessThan(new Date(), new Date()); // OK
isLessThan(new Date(), Date.now()); // OK (Date/number)
isLessThan(12, 23); // OK
isLessThan('A', 'B'); // OK
// Invalid comparison - correctly rejected
isLessThan(12, 'B'); // Error: string not assignable to number
// Union case - correctly rejected
let dateOrStr: Date | string;
isLessThan(dateOrStr, 'B'); // Error: string not assignable to never
耐圧プロトコル
条件型が予想外の動作をするときは、次の手順に従ってください。
- 分配を確認する:分配すべきでない場面で、型がユニオンに対して分配されていますか?
- boolean/never でテストする:これらの型は分配の問題を明らかにすることがよくあります
- 1 要素のタプルで囲む:
[T] extends [X]は分配を防ぎます - 裸の条件を追加する:
N extends anyは分配を強制します - ユニオンで検証する:ユニオン入力を使って型をテストします
危険信号
| 症状 | 原因 | 修正方法 |
|---|---|---|
boolean が予想外の結果になる | 分配 | [T] で囲む |
never が never になる | 空のユニオン | [T] で囲む |
| ユニオンが正しく分割されない | 分配されていない | 裸の N extends を追加 |
| インターセクションが必要なのにユニオンになる | 分配 | [T] で囲む |
よくある言い訳
「複雑なケースでは any を使えばいい」
実際には:分配を理解すれば、型を正確に制御できます。any を使うと、型安全性がすべて失われます。
「このユースケースには複雑すぎる」
実際には:1 要素のタプルを使う方法は簡単です。[T] extends [X] と T extends X の違いを覚えれば、ずっと活用できます。
「型システムはこんなふうに動作すべきではない」
実際には:分配は強力な機能です。これを理解すれば、機能に逆らうのではなく、その力を活用できます。
クイックリファレンス
| 目的 | 構文 | 例 |
|---|---|---|
| 分配を許可する | T extends X | デフォルトの動作 |
| 分配を防ぐ | [T] extends [X] | ユニオン、boolean、never の場合 |
| 分配を強制する | N extends any ? ... : never | 再帰型の場合 |
まとめ
ユニオンに対する分配は通常望ましい動作ですが、常にそうとは限りません。分配を防ぐには [T] extends [X] を使い、強制するには裸の条件を使います。予想外の挙動を避けるために、boolean と never の動作を理解しましょう。
参考資料
- Dan Vanderkam 著『Effective TypeScript, 2nd Edition』
- 項目 53:条件型におけるユニオンの分配を制御する方法を知る