Intersection Typesの罠:なぜあなたの型定義は `never` に吸い込まれるのか
TypeScriptの型システムは強力だが、その「自由度」は時として開発者を奈落へ突き落とす。特に `&` 演算子を用いた Intersection Types(交差型) は、安易に使うとプロパティの衝突を招き、最悪の場合、型システムがその型を「存在し得ないもの」として `never` に評価する。
今日は、インターフェースや型エイリアスを設計する際、避けては通れない「型合成の深淵」について、コンパイラの視点から解説する。
—
1. 「衝突」の正体:型システムはどう評価しているか
Intersection Types `A & B` は、「AかつBの全てのプロパティを保持する」 という契約だ。しかし、同じプロパティ名で異なる型が定義された場合、コンパイラはどう振る舞うのか。
type A = { id: string; status: number };
type B = { id: number; active: boolean };
type AB = A & B;
// 評価結果: { id: string & number; status: number; active: boolean; }
ここで `id` は `string & number` となる。`string` かつ `number` である値は存在しないため、`id` は `never` 型になる。結果として `AB` 型のインスタンスを作成しようとすると、`id` に何を代入してもエラーが出る、いわば「死んだ型」が完成する。
教訓: プロパティの衝突は、単なる警告ではなく、型安全性の崩壊を意味する。
—
2. 現場で遭遇する「意図しないnever」のデバッグ術
複雑なAPIレスポンスの合成などでよくあるのが、階層構造の深い型で `never` が混入するケースだ。これを発見するための「デバッグ用ユーティリティ型」を伝授する。
/
- 型がneverに評価されていないかチェックするユーティリティ
/
type AssertNotNever
// APIの共通型定義
type UserBase = { id: string; meta: { version: number } };
type UserDetail = { id: number; meta: { version: string } };
// この合成は危険
type BrokenUser = AssertNotNever
// エラーは出ないが、ホバーすると ‘id’ と ‘meta’ が内部的にnever化しているのが一目でわかる
この `AssertNotNever` を型定義の末尾に添えるだけで、コンパイル時に「あ、ここ死んでるな」と即座に気づけるようになる。
—
3. プロダクションコードにおける「堅牢な設計パターン」
インターフェースや型エイリアスを合成する際、安易に `&` を使うのではなく、「差異を吸収する設計」 を取り入れるべきだ。
アンチパターン: 継承や合成を強制する
// 変更に弱く、衝突しやすい
type Plugin = Base & Theme & Config;
推奨パターン: Mapped Types と Omit を使った上書き
プロパティが衝突することが分かっているなら、`Omit` で旧来の型を排除し、明示的に上書きするのが最も「美しい」コードだ。
type UserBase = { id: string; role: ‘guest’ | ‘admin’ };
// UserBaseのidをnumberに上書きしつつ、他のプロパティは継承する
type AdminUser = Omit
id: number;
role: ‘admin’;
permissions: string[];
};
const admin: AdminUser = {
id: 1, // stringではなくnumberを許可
role: ‘admin’,
permissions: [‘read’, ‘write’]
};
—
4. パフォーマンスと型評価のコスト
TypeScriptのコンパイラは型が複雑になればなるほど、型の再帰的な評価に時間がかかる。特に、巨大なオブジェクトに対して `&` を乱用すると、IDEの補完が遅くなり、ビルド時間も増大する。
- 名前付き型を優先する: インラインでの `A & B & C` は避け、`type Combined = A & B` のように名前をつけてインターフェースとして定義する。これによりコンパイラは型をキャッシュしやすくなる。
- IntersectionよりInterfaceの継承: `interface` の `extends` は、競合した際にエラーを通知してくれることが多い。型エイリアス(`type`)の `&` は、静かに型を壊すことがあるため、公開APIの定義には `interface` を推奨する。
—
結論:型システムは「契約」である
Intersection Typesは強力だが、それは「全ての要素が矛盾なく共存できる」という前提があってこそ機能する。
1. 衝突を予期せよ: 異なるドメインの型を混ぜる際は、必ず `Omit` で衝突箇所を整理する。
2. `never` を可視化せよ: デバッグ用ユーティリティ型を活用し、型が死ぬ前に叩く。
3. `interface` を活用せよ: 構造の安定性が求められる設計では、`type` よりも `interface` の `extends` が圧倒的に堅牢だ。
TypeScriptの型エラーは、コードの「論理的矛盾」を教えてくれる親切な警告だ。それを「面倒なもの」として放置するのではなく、コンパイラと対話し、より強固なドメインモデルを構築してほしい。
君たちが書くその一行が、明日誰かのバグを未然に防ぐことを信じている。