状態の虚偽をコンパイル時に粉砕する:Discriminated Unionsによる型安全の極限領域
ランタイムの荒野において、バグの大部分は「存在してはならない状態の組み合わせ」が実行された瞬間に産み落とされる。例えば、非同期のネットワークリクエストを思い出してほしい。`isLoading` が `true` であるにもかかわらず、前回のレスポンスデータである `data` が存在し、かつ `error` も埋まっている――このような「論理的破綻を孕んだオブジェクト」がメモリ上に生成された時点で、そのアプリケーションの型安全性は形骸化している。
一般的なフロントエンド開発では、これを防ぐためにオプショナルチェイニング (`?.`) や、場当たり的な `if` 文によるガードを乱用しがちだ。しかし、それはランタイムの防衛的プログラミングに依存しているに過ぎない。
TypeScriptの型システムの本質は、「不可能な状態を表現不能にする(Make impossible states unrepresentable)」ことにある。本稿では、関数引数における「Discriminated Unions(判別可能Union型)」を駆使し、コンパイラの型推論エンジンを極限までハックして、状態管理の安全性を物理法則レベルで担保するアーキテクチャを解説する。
—
1. コンパイラはUnionをどう評価しているのか?
TypeScriptにおけるUnion型 (`A | B`) は、集合論における「直和(Disjoint Union / Sum Type)」としてコンパイラ内部で構築される。
type State =
| { status: ‘IDLE’ }
| { status: ‘LOADING’; progress: number }
| { status: ‘SUCCESS’; data: Payload }
| { status: ‘FAILURE’; error: NetworkError };
このとき、TypeScriptの型チェッカー(Checker)は、共通のプロパティ(ここでは `status`)を「ディスクリミネータ(判別子)」として認識する。
条件分岐(`switch` や `if`)で `state.status` を評価した瞬間、コンパイラはフロー解析(Control Flow Analysis)を走りさせ、Unionの大部分を型空間から「削ぎ落とす(Narrowing)」。
function handleResponse(state: State) {
if (state.status === ‘SUCCESS’) {
// このブロック内において、TypeScriptは state を
// { status: ‘SUCCESS’; data: Payload } に完全に絞り込む。
// 存在しない error や progress にアクセスしようものなら、
// コード生成以前にコンパイラがビルドを即座に停止する。
console.log(state.data);
}
}
この挙動は単なるIDEの補完支援ではない。V8等のJSエンジンが実行時に行うインラインキャッシュ(IC)の最適化や、hidden classの遷移予測とも美しく共鳴する。型が確定している領域では、プロパティアクセスのオフセットが静的に定まるため、ランタイムのV8側でも最適化コード(TurboFanによるJITコンパイル)が生成されやすくなるという副次的な恩恵もある。
—
2. アンチパターン:フラットなオプショナル地獄
多くのシニア未満の開発者が陥る罠が、すべての状態を1つの巨大なフラットオブジェクトに詰め込み、オプショナルで表現する設計だ。
// 【極めて危険なアンチパターン】
interface DangerousContext {
isLoading: boolean;
data?: Payload;
error?: NetworkError;
progress?: number;
}
この設計の罪深い点は、「ローディング中でありながら、古いデータとエラーが同時に存在する」という、宇宙物理学的にありえない状態をTypeScriptの型システムが許容してしまうことにある。結果として、関数内部で以下のような無駄なガード構文の迷宮が生成される。
function processDangerous(ctx: DangerousContext) {
// 開発者は「絶対にこうならない」と信じ込んでいるが、
// 型システムはそれを保証してくれないため、永遠にundefinedチェックが必要になる
if (!ctx.isLoading && ctx.data && !ctx.error) {
// 業務ロジック
}
}
これはコードの肥大化を招くだけでなく、イベントループの非同期処理や複雑なステートマシーンの遷移において、メモリ上の不整合なゴミデータを放置する温床となる。
—
3. 実践:イベントループのキュー消費とDiscriminated Unions
ここで、より実践的な例として、高頻度でイベントを受け取り、非同期でバックエンドへフラッシュ(バッチ送信)するキューイング・プロセッサを設計しよう。
このプロセッサは以下の3つの排他的な状態を持つ。
1. Idle: 待機中。新しいイベントのエンキューを受け付ける。
2. Flushing: バッファをネットワーク層へ送信中。この間、新規のエンキューはロックされるか、別バッファへ退避される。
3. Terminated: 終了状態。一切の操作を受け付けない。
これをDiscriminated Unionsで厳密に表現し、関数引数として型安全に流し込むアーキテクチャを構築する。
// — 1. プリミティブなドメイン型 —
type EventId = string & { readonly __brand: unique symbol };
interface DomainEvent {
readonly id: EventId;
readonly payload: ArrayBuffer;
readonly timestamp: number;
}
// — 2. 状態の直和(Discriminated Unions) —
type QueueState =
| {
readonly status: ‘IDLE’;
readonly buffer: readonly DomainEvent[];
readonly capacity: number;
}
| {
readonly status: ‘FLUSHING’;
readonly buffer: readonly DomainEvent[];
readonly inFlightBatchId: string;
readonly retryCount: number;
}
| {
readonly status: ‘TERMINATED’;
readonly reason: string;
readonly finalStats: { processedCount: number; droppedCount: number };
};
// — 3. 状態遷移関数のシグネチャ(極限の型安全) —
/
- イベントをエンキューする。
- 状態が ‘IDLE’ の場合のみバッファへのプッシュが型として許可される。
- ‘FLUSHING’ や ‘TERMINATED’ の状態で呼び出された場合、コンパイルエラーとなる。
/
function enqueueEvent(
state: QueueState,
event: DomainEvent
): QueueState {
switch (state.status) {
case ‘IDLE’: {
// 配列のイミュータビリティを保つため、readonly配列に対して新規要素を追加した新しい状態を返す
if (state.buffer.length >= state.capacity) {
// 容量オーバーフロー時の処理(簡易的にそのまま返す)
return state;
}
return {
…state,
buffer: […state.buffer, event],
};
}
case ‘FLUSHING’:
// ランタイムの例外ではなく、コンパイル時に「フラッシュ中のエンキューは設計上不可能」であることを強制
throw new Error(‘Cannot enqueue while flushing. Architecture violation.’);
case ‘TERMINATED’:
throw new Error(`Queue is terminated. Reason: ${state.reason}`);
}
}
/
- バッファのフラッシュ処理を開始する。
- ‘IDLE’ 状態からのみ ‘FLUSHING’ へ遷移可能。
/
function initiateFlush(state: QueueState, batchId: string): QueueState {
if (state.status !== ‘IDLE’) {
// ガード句によるナローイング
throw new Error(`Invalid state transition from ${state.status} to FLUSHING`);
}
// バッファが空なら遷移する必要なし
if (state.buffer.length === 0) {
return state;
}
return {
status: ‘FLUSHING’,
buffer: state.buffer,
inFlightBatchId: batchId,
retryCount: 0,
};
}
この設計がもたらす圧倒的な優位性
1. 不正な状態遷移の根絶:
`enqueueEvent` の内部で `state.status === ‘IDLE’` の分岐に入った瞬間、TypeScriptは `state` が `buffer` プロパティを持つことを完全に保証する。もし将来的にプロパティ名が変更されたりした場合も、コンパイラが全コードベースを追跡し破壊的変更を検知する。
2. 網羅性チェック(Exhaustiveness Checking)の強制:
`switch` 文において `never` 型を用いた網羅性チェックを組み込むことで、新しい状態(例: `PAUSED`)を追加した際、対応するハンドリングを書き忘れると即座にコンパイルエラーが発生する。
function assertNever(x: never): never {
throw new Error(`Unexpected object: ${x}`);
}
// switch文のデフォルト節などで利用
// default: return assertNever(state);
—
4. チーフアーキテクトからの提言:型は「ドキュメント」ではなく「物理法則」である
多くのエンジニアは、型定義を「JSDocの豪華版」や「IDEの補完を効かせるための補助ツール」程度に捉えている。それは大きな誤りだ。
TypeScriptの型システムは、チューリング完全な静的検証エンジンである。
関数引数に素のプリミティブやフラットなオプショナルオブジェクトを渡すことは、鍵の掛かっていない金庫に全資産を放り込むようなものだ。Discriminated Unionsを駆使して「その瞬間、システムに存在して良いデータ構造」を極限まで絞り込み、不可能な状態をコンパイルエラーによって物理的に排除せよ。
ランタイムの例外処理に怯える時代は終わった。
コンパイラを厳格な検問所として機能させ、本番環境のメモリ空間には、美しく調律された純粋なデータのみを存在させろ。それが、プロフェッショナルなアーキテクトが到達すべき、コードの極北である。