【テクニカル・上級編】TypeScriptのインターセクション型(&)とプリミティブ型の奇妙な関係 – TypeScript コア・型システムの基礎解析バイブル

プリミティブの交叉(Intersection)が導く型崩壊:TypeScriptコンパイラの内側と`never`の必然

チーフシステムアーキテクトの私だ。日頃、何百万行をも超えるコードベースの型安全性を担保するため、コンパイラの挙動やランタイムの挙動を低レイヤから見つめ直している。

今回は、TypeScriptの型システムにおける最も美しく、そして多くのエンジニアを混乱の渦に突き落とす現象——「異なるプリミティブ型をインターセクション(`&`)で結合した瞬間に発生する `never` への収束」について、コンパイラ内部の型評価メカニズム、そしてランタイムのメモリ最適化やイベントループの文脈まで踏み込んで解き明かそう。

ネットの海に溢れる「リファレンスの引き写し」はここで終わりだ。今日、君はこの言語の根底にある「論理の整合性」を完全に見据えることになる。

—

1. プリミティブ交叉の核心:なぜ `string & number` は `never` になるのか?

まずは、誰もが一度は遭遇するであろうこのコードを見てほしい。

type Impossible = string & number;
// 評価結果: type Impossible = never

なぜ、文字列型と数値型を掛け合わせると(インターセクションすると)、`never` という「存在し得ない型」が出現するのだろうか?

表面的な理解として「文字列でありかつ数値である値なんてJavaScriptに存在しないから」と答えるなら、それは半分正解で半分間違いだ。TypeScriptの型システムは、JavaScriptの実行時挙動を模倣しているだけではない。これは集合論における「積集合(Intersection)」の厳密な数学的帰結である。

型の正体は「値の集合(Domain)」である

TypeScriptの型システムにおいて、型とは「その変数が取り得る値の無限(あるいは有限)の集合」に他ならない。

  • `string` 型:JavaScript上のすべての文字列リテラルおよびStringオブジェクトの集合。
  • `number` 型:すべての数値(NaNやInfinityを含む)の集合。

この2つをインターセクション演算子 `&` で結ぶということは、「両方の集合に同時に属する値(積集合)」を求めていることを意味する。

string の集合 number の集合
┌─────────────────┐ ┌─────────────────┐
│ “hello”, “foo” │ │ 42, 3.14, NaN │
└─────────────────┘ └─────────────────┘
│ │
└───────────┬───────────┘
▼
【交わる要素は存在しない】
│
▼
┌───────────────│
│ never (空集合)│
└───────────────┘

数学的に、互いに素(disjoint)な集合同士の積集合は空集合(Empty Set)になる。TypeScriptコンパイラはこの空集合を `never` という底型(Bottom Type)として表現する。つまり、`never` とは「コンパイルエラーを引き起こすための特殊な記号」ではなく、「論理的に構築不可能な値の領域」を指し示す厳密な物理法則なのだ。

—

2. コンパイラ内部:`checker.ts` における型の正規化と縮小

では、TypeScriptコンパイラ(tsc)の内部、とりわけ型チェッカー(`checker.ts`)はこのインターセクションをどのように評価しているのだろうか?

コンパイラは、ふたつの型を `&` で結合した際、内部的に `intersectTypes` 関数を呼び出す。この関数は、単にAST(抽象構文木)を結合するだけでなく、以下のアルゴリズムで型の正規化(Normalization)を行っている。

1. プリミティブの互換性チェック: 結合される双方がプリミティブ型である場合、それらが同一のプリミティブか、あるいはサブタイプ関係にあるかを判定する。
2. 素性の判定: `string` と `number` は、共通のスーパータイプ(`any` や `unknown` を除く)を持たない完全な独立プリミティブである。
3. 空集合への収束: 共通部分が論理的に存在しないと判定された瞬間、コンパイラは即座に評価を打ち切り、その部分型を `NeverType`(内部表現としての `never`)に置換する。

リテラル型における「縮小」の例外

ただし、すべてが `never` になるわけではない。プリミティブの「スーパータイプ」と「リテラルタイプ」の組み合わせでは話が違う。

type Specific = string & “hello”; // type Specific = “hello”
type Invalid = “hello” & “world”; // type Invalid = never

  • `string & “hello”` は、`string` という広大な集合の中に `”hello”` という部分集合が含まれているため、積集合は `”hello”` 自体になる(包含関係)。
  • `”hello” & “world”` は、どちらも文字列リテラルだが互いに排他的であるため、やはり共通部分はなく `never` に崩壊する。

コンパイラはこの判定を毎秒数万回、静的解析のパイプラインの中で瞬時に行っている。この高速な型チェックの裏には、プリミティブ同士の素性をビット演算的に処理する最適化が組み込まれている。

—

3. 実践と防壁:なぜこの挙動がセキュリティやアーキテクチャで重要なのか?

「そんな `never` になる特殊なコードなんて書かないよ」と思ったなら、大規模開発における型安全性の罠を見落としている。

厳密な型設計や、外部API、ランタイムバリデーション(ZodやValibotなど)の型推論レイヤー、さらにはTypeScriptの高度な条件付き型(Conditional Types)を駆使する際、この「プリミティブのインターセクションによる `never` への崩壊」は、意図せぬバグを防ぐ防壁としても、型パズルの難敵としても猛威を振るう。

パターンA:条件付き型における「型の分配(Distributive Conditional Types)」での事故

ジェネリクスの中でプリミティブ型を絞り込もうとした際、意図せず `never` が生成され、ロジックが死ぬ現象がある。

// T が string の場合のみ型を抽出し、それ以外を排除したいユーティリティ
type ExtractString = T extends string ? T : never;

// ここまでは正常
type A = ExtractString;
// 評価: string (number は never に落ちてユニオンから消滅する)

しかし、これをインターセクションと誤認して組み合わせると、コンパイラは型を解決できなくなる。

// 悪夢のミスマッチ
type BadIntersection = T & string;

// もし T に number を渡したら?
type Result = BadIntersection;
// 評価: number & string -> never

この `never` がコードの奥底で生成されると、関数のオーバーロードやMapped Typesのキーマップにおいて、プロパティが突如として消失(消失したキーはビルドエラーや実行時undefinedを誘発)する原因となる。

パターンB:厳密なブランド型(Branded Types)の構築

逆に、この「プリミティブ同士の非互換性」を積極的に利用して、ドメイン駆動設計(DDD)における型安全なプリミティブ(Value Object)を作り上げることもできる。これが「ブランド型(Branded Types)」だ。

// 通常のstring同士では区別がつかない
type UserId = string & { readonly __brand: unique symbol };
type OrderId = string & { readonly __brand: unique symbol };

function processOrder(id: OrderId) {
// …
}

const userId = “user_123” as unknown as UserId;

// コンパイルエラー!
// string ベースだが、__brand の構造が異なるため、
// UserId & OrderId は実質的に never に近い型不整合を引き起こし、代入が拒絶される。
processOrder(userId);

ここで `UserId` の実体はランタイムでは単なる `string` だ。しかし、コンパイル時には `{ readonly __brand: unique symbol }` という「決して満たされない(しかし構造的には存在する)メタデータ」とのインターセクションとして定義されることで、TypeScriptの構造的型付け(Structural Subtyping)の隙を突き、名目的手法(Nominal Typing)を強制している。

この設計手法の根底にあるのも、まさに「プリミティブとオブジェクトのインターセクション」が生む型制約の魔術である。

—

4. ランタイムとイベントループの視座:型が消えたあとの世界

シニアエンジニアなら常に意識しなければならないのは、「TypeScriptの型は、コンパイル(トランスパイル)された瞬間にすべて消え去る」という事実だ。

`string & number` が生み出した `never` は、JavaScriptのランタイム(V8やSpiderMonkey、JavaScriptCoreなど)においては1バイトたりともメモリを占有せず、実行時コードにも一切影響を与えない。

しかし、ここにコンパイラとランタイムの哲学的な乖離と、エンジニアが直面するリスクがある。

型の整合性とイベントループのキュー消費

TypeScriptの型チェッカーが完璧に `never` を検知し、コンパイルエラーを吐いてくれるおかげで、私たちは「実行時における型ミスマッチによるクラッシュ」を未然に防いでいる。

もし、この `string & number` のような不条理なインターセクションが、もし仮にランタイムまで持ち込まれたとしたらどうなるか?
Node.jsやブラウザのイベントループ(Event Loop)において、非同期タスクやプロミスチェーン(Microtask Queue)の最中に、想定外のプリミティブ型が混入し、V8のインラインキャッシュ(Inline Caching)や隠しクラス(Hidden Classes)の最適化が破綻する。結果として、メモリの脱落やGC(ガベージコレクション)のスパイク、あるいは最悪の場合、型推測の崩壊によるセグメンテーション違反やランタイムの強制終了を引き起こす。

TypeScriptコンパイラは、まさにその「混沌としたランタイムの暴走」を、静的型付けの要塞で食い止める防壁なのだ。

`never` 型はその防壁の最前線に位置している。「ここから先は論理的にあり得ない領域だ。これ以上コードを進めるな」という、コンパイラからの強烈なシグナルである。

—

5. 結び:型システムを「掌握」するということ

TypeScriptのインターセクション型とプリミティブの関係は、単なる言語仕様の隅っこにあるトリビアではない。それは、「論理の厳密性(集合論)」と「現実の動的言語(JavaScript)」の間に架けられた、極限の緊張感を孕んだ橋である。

`string & number` が `never` になるという現象は、コンパイラがどれほど冷徹に、そして正確にコードの論理的破綻を見抜いているかの証明に他ならない。

日々の開発において、型エラーとして現れる `never` に遭遇したとき、ただ焦って `as any` で逃げるのはアーキテクトとして最も愚かなアプローチだ。
「今、どの集合とどの集合が衝突し、なぜ空集合(`never`)へと収束したのか?」
その背後にあるコンパイラの思考プロセスを脳内トレースできたとき、あなたはもはや「TypeScriptに使われているエンジニア」ではなく、「TypeScriptの型システムを掌の上で転がすチーフアーキテクト」へと到達しているはずだ。

コードを書け。そして、型に命を吹き込め。

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