【テクニカル・上級編】 判別可能なユニオン型(Discriminated Unions) – TypeScript実践ガイド

判別可能なユニオン型(Discriminated Unions):型安全とパフォーマンスの極限を求めて

フロントエンドの規模が肥大化するにつれ、アプリケーションの寿命を縮める最大の要因は何だか知っているかね? そう、「不正な状態の存在を許す型定義」だ。

APIから返ってくるレスポンス、UIの状態管理、ReduxやZustandなどのステート、複雑なフォームのバリデーション結果……。これらを適当なオプショナルプロパティの寄せ集めで表現している現場を見ると、私は思わず頭を抱えたくなる。`undefined`の嵐、無駄なオプショナルチェイニング、そして「動かしてみないと分からない」という恐怖。

今回は、TypeScriptが持つ最強の武器の一つでありながら、その真価が意外と理解されていない「判別可能なユニオン型(Discriminated Unions)」について、ブラウザのエンジン挙動やコンパイラの最適化、そして実務の泥臭いアーキテクチャの観点から徹底的に深掘りしていこう。

—

1. なぜ「オプショナルまみれの型」は悪なのか

まずは、よくあるアンチパターンから見ていこう。APIから取得する「非同期データの状態」を表現するために、以下のような型を定義してしまう開発者は多い。

// 良くあるアンチパターン:すべてのプロパティがオプショナルまたは混在している
interface AsyncState {
data?: T;
error?: Error;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
}

この型の恐ろしさは、「データが存在するのに `isLoading` が `true` である」という、論理的にあり得ない矛盾した状態(Impossible States)をTypeScriptの型システムが許容してしまう点にある。

結果として、コンポーネントのレンダリング部では以下のような冗長でバグの温床となるコードを書かざるを得なくなる。

// どこまでいっても「本当にこの状態で合っているか?」という不安がつきまとう
if (state.isLoading) {
return ;
}
if (state.isError && state.error) {
// errorがundefinedの可能性を消すためにガードが必要
return ;
}
if (state.isSuccess && state.data) {
return ;
}
return null; // 「ここには到達しないはず」のデッドコード

これはTypeScriptを使っている意味を半分放棄しているようなものだ。型とは、単なる補完ツールではない。「ドメインのビジネスロジックにおける不変条件(Invariants)をコードとしてコンパイル時に強制する防壁」なのだから。

—

2. 判別可能なユニオン型(Discriminated Unions)によるパラダイムシフト

ここで登場するのが、判別可能なユニオン型(別名:代数的データ型 / Algebraic Data Types)だ。

概念は極めてシンプル。共通の識別子(通常は `status` や `type` といったリテラル型のプロパティ、通称「タグ」)を持つ複数のオブジェクト型を、ユニオン(`|`)で結ぶだけだ。

先ほどの非同期状態を、プロフェッショナルなアーキテクチャで再定義してみよう。

// 成功、失敗、ローディング、それぞれの状態を完全に分離した型定義
type AsyncState =
| { status: ‘idle’ }
| { status: ‘loading’ }
| { status: ‘success’; data: T }
| { status: ‘error’; error: Error };

この定義の美しさは、「それぞれの状態で必要なプロパティ以外は存在すらしない」という点にある。`success` の状態であれば必ず `data` が存在し、`error` の状態であれば必ず `error` が存在する。無駄なオプショナルプロパティは一切排除されている。

そして、TypeScriptの制御フロー分析(Control Flow Analysis)が、このタグ(`status`)を検知して自動的に型を絞り込んでくれる(Narrowing)。

function DataComponent({ state }: { state: AsyncState }) {
// statusの値によって、TypeScriptは完全に型を安全に絞り込む
switch (state.status) {
case ‘idle’:
return

待機中…

;

case ‘loading’:
return ;

case ‘error’:
// stateは自動的に { status: ‘error’; error: Error } に絞り込まれる
// state.dataにアクセスしようとすると、TypeScriptが即座にコンパイルエラーを出してくれる
return ;

case ‘success’:
// stateは自動的に { status: ‘success’; data: T } に絞り込まれる
return ;

default:
// 網羅性チェック(Exhaustiveness Checking)
// 将来的に新しいステート(例: ‘refetching’)が追加された際、ここに網羅されていないとコンパイルエラーになる
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}

この `default` 句での `never` 型を使った網羅性チェック(Exhaustiveness Checking)こそ、上級エンジニアが好むテクニックだ。型定義を拡張した際に、switch文のハンドリング漏れをコンパイル時に100%検知できる。これにより、チーム開発におけるヒューマンエラーを完全に根絶できるのだ。

—

3. コンパイラ最適化とV8エンジンから見たメモリ効率の話

ここからは、一歩踏み込んで「JavaScriptのランタイム挙動」と「TypeScriptの型システム」の裏側の話学をしよう。ギークならこの視点にこそ痺れるはずだ。

TypeScriptの型はコンパイル時に消え去り、最終的には素のJavaScriptオブジェクトになる。では、判別可能なユニオン型を採用したオブジェクトは、ブラウザのJavaScriptエンジン(V8など)の内部でどのように扱われるのだろうか?

Hidden Classes(隠しクラス)とインラインキャッシュの最適化

V8エンジンなどのモダンなJSエンジンは、オブジェクトのプロパティアクセスを高速化するために Hidden Classes(隠しクラス / Shapes) という仕組みを使っている。

オブジェクトが生成される順序やプロパティの構造が同じであれば、それらは同じ隠しクラスを共有し、プロパティのオフセット(メモリ上の位置)が固定されるため、高速なプロパティアクセス(インラインキャッシュ)が可能になる。

ここで、すべての状態を1つの巨大なオブジェクトに詰め込んでオプショナルにしていた場合を考えてほしい。

// 悪い例:プロパティの有無がバラバラで、V8の最適化が効きにくい可能性
{ data: undefined, error: Error, isLoading: false, isSuccess: false, isError: true }

このような構造は、JSエンジンにとって予測しづらく、メモリの効率やプロパティアクセスの速度に悪影響を与えることがある。

一方、判別可能なユニオン型によって生成されるオブジェクトは、それぞれの状態ごとに完全に固定された構造(Shape)を持つ。

// 良い例:状態ごとの構造が完全に一致しているため、V8が予測しやすい
// 成功時
{ status: ‘success’, data: […] }
// エラー時
{ status: ‘error’, error: […] }

これにより、V8エンジンはそれぞれのオブジェクトに対してクリーンで一貫性のある隠しクラスを割り当てることができ、メモリレイアウトの最適化とプロパティアクセスの高速化の恩恵を受けやすくなる。型安全だけでなく、ランタイムのパフォーマンスの観点からも、判別可能なユニオン型は理にかなっているのだ。

—

4. 実務で遭遇する複雑な非同期競合と状態管理への応用

実務の現場では、単なる「ローディング・成功・失敗」だけでなく、より複雑な非同期の競合状態(Race Conditions)を扱う必要がある。例えば、「検索窓へのタイピング入力に伴う非同期フェッチと、キャンセル処理・キャッシュの組み合わせ」だ。

ここでも判別可能なユニオン型は圧倒的な表現力を発揮する。

// 検索UIの複雑な状態を表現するユニオン型
type SearchState =
| { status: ‘idle’ }
| { status: ‘typing’; query: string } // 入力中だがまだフェッチしていない
| { status: ‘fetching’; query: string; previousData: T | null } // フェッチ中(前回のデータを保持しつつローディング)
| { status: ‘success’; query: string; data: T } // 成功
| { status: ‘error’; query: string; error: Error; previousData: T | null }; // エラー(前回のデータをフォールバックとして保持)

この型設計をしていれば、「フェッチ中にユーザーが追加でタイピングした際、どのデータを画面に表示し、どのクエリで再リクエストを送るべきか」という複雑なステートマシンを、TypeScriptの型に守られながら実装できる。

function handleQueryChange(currentState: SearchState, newQuery: string): SearchState {
if (newQuery === ”) {
return { status: ‘idle’ };
}

// 現在のステータスに応じて、安全に次の状態へ遷移させる
switch (currentState.status) {
case ‘success’:
return { status: ‘fetching’, query: newQuery, previousData: currentState.data };
case ‘error’:
return { status: ‘fetching’, query: newQuery, previousData: currentState.previousData };
case ‘fetching’:
return { status: ‘fetching’, query: newQuery, previousData: currentState.previousData };
default:
return { status: ‘typing’, query: newQuery };
}
}

このように、状態遷移関数(Reducer)の入力と出力を判別可能なユニオン型で厳格に縛ることで、「あり得ない状態遷移のバグ」を根本からシャットアウトできる。ReduxやReactの `useReducer` を使っているプロジェクトであれば、このパターンを導入するだけでバグの数が劇的に減るはずだ。

—

5. まとめ:型は「ドキュメント」であり「防壁」である

私たちは日々、コードを書いている。しかし、優れたエンジニアが書いているのは単なるコードではない。「未来の自分やチームメンバーへの強固な意思表示」であり「システムの堅牢性を担保する防壁」だ。

オプショナルプロパティの海に溺れ、ランタイムエラーにおびえる日々に終止符を打とう。

判別可能なユニオン型を使いこなし、
1. 不正な状態を型レベルで表現不可能なものにする
2. 制御フロー分析を活用して、安全かつ簡潔な分岐を書く
3. 網羅性チェック(`never`)で将来の変更漏れを防ぐ

この3つを徹底するだけで、あなたの書くTypeScriptコードの品質は、プロフェッショナルとして一つ上のステージに到達する。

さあ、今すぐエディタを開き、プロジェクトの中にある怪しいオプショナルまみれの型を、美しい判別可能なユニオン型へとリファクタリングしてみないか? 型の神様は、細部に宿るのだから。

コメント

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