TypeScriptを用いたアプリケーション開発において、状態管理の型安全性はバグを未然に防ぐための極めて重要な要素です。特に、API通信における「ローディング」「成功」「エラー」といった状態遷移や、多様な種類のユーザーアクションを扱う際、型定義が曖昧だと予期せぬランタイムエラーを引き起こす原因となります。
本記事では、TypeScriptの強力な型機能である**タグ付きユニオン(Discriminated Unions / 判別可能なユニオン型)**を活用し、安全かつメンテナンス性の高い状態管理を実現する設計アプローチについて解説します。
1. タグ付きユニオン(Discriminated Unions)とは?
タグ付きユニオンとは、複数の異なる型を1つに結合した「ユニオン型(|)」であり、それぞれの型が共通の「リテラル型(タグ)」プロパティを持つパターンを指します。
TypeScriptのコンパイラは、この「タグ」の値を条件分岐(if や switch)で判定することで、分岐の内部でオブジェクトの型を自動的に絞り込む(Type Narrowing)ことができます。
判別可能なユニオン型の3大要素:
- 複数のオブジェクト型が存在する。
- それぞれの型が、**共通の名前のプロパティ(タグ)**を持っている。
- そのタグの値は、一意のリテラル型(文字列リテラルや数値リテラル、または
true/false)である。
2. 良くない設計パターン(単一オブジェクトでの状態表現)
まずは、タグ付きユニオンを使わずに、1つのオブジェクトにすべての状態を含めてしまうアンチパターンを見てみましょう。
// 良くない例:すべての状態のプロパティをオプショナルで持たせる
interface FetchState<T> {
isLoading: boolean;
data?: T;
error?: Error;
}
function renderState<T>(state: FetchState<T>) {
if (state.isLoading) {
return "Loading...";
}
// isLoadingがfalseであっても、dataやerrorが本当に存在するか保証されない
if (state.data) {
// state.dataはオプショナルであるため、本来は未定義チェックが必要
return `Data: ${JSON.stringify(state.data)}`;
}
if (state.error) {
return `Error: ${state.error.message}`;
}
return "Unknown state";
}
この設計の問題点:
isLoading: falseのときにdataが存在するのかerrorが存在するのかが型レベルで曖昧です。dataとerrorが両方存在してしまったり、あるいは両方ともundefinedになってしまう「矛盾した状態」を定義できてしまいます。
3. タグ付きユニオンによる洗練された設計
次に、タグ付きユニオンを用いて状態を完全に分離し、矛盾のない型安全な設計に書き直してみましょう。
// 状態ごとに型を完全に独立させる
interface IdleState {
type: 'idle';
}
interface LoadingState {
type: 'loading';
}
interface SuccessState<T> {
type: 'success';
data: T;
}
interface ErrorState {
type: 'error';
error: Error;
}
// 4つの状態をユニオン型で結合
type FetchState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;
このように定義すると、type プロパティが「タグ」として機能します。
状態のハンドリングとスマートな型絞り込み:
function renderState<T>(state: FetchState<T>): string {
switch (state.type) {
case 'idle':
return '待機中...';
case 'loading':
return '読み込み中...';
case 'success':
// ここでは、stateがSuccessStateであることが保証され、dataに安全にアクセスできます
return `データ: ${JSON.stringify(state.data)}`;
case 'error':
// ここでは、stateがErrorStateであることが保証され、errorに安全にアクセスできます
return `エラー発生: ${state.error.message}`;
}
}
この実装では、state.type が 'success' のスコープ内では、state.data が確実に存在することが保証されます。オプショナル型にありがちな「?」や非ヌル保証演算子「!」を使う必要は一切ありません。
4. 網羅性チェック(Exhaustiveness Check)による防衛的設計
タグ付きユニオンの真の強みは、将来的に新しい状態が追加された際、実装漏れをコンパイルエラーとして検出できる点にあります。これを**網羅性チェック(Exhaustiveness Check)**と呼びます。
以下のように never 型を活用することで、switch 文の漏れを防ぐことができます。
function assertNever(value: never): never {
throw new Error(`Unhandled discriminator: ${JSON.stringify(value)}`);
}
function renderStateWithGuard<T>(state: FetchState<T>): string {
switch (state.type) {
case 'idle':
return '待機中...';
case 'loading':
return '読み込み中...';
case 'success':
return `データ: ${state.data}`;
case 'error':
return `エラー: ${state.error.message}`;
default:
// もし将来 'canceling' という状態が追加され、switchでハンドリングしていなければ、
// ここでコンパイルエラーが発生します。
return assertNever(state);
}
}
まとめ
タグ付きユニオン(Discriminated Unions)を使用することで、アプリケーションに以下のメリットをもたらします。
- 矛盾した状態の排除: データとエラーが同時に存在するような不正状態を防止する。
- 型安全なデータアクセス: 不要な未定義チェックやキャストを排除し、コードの可読性を向上させる。
- 安全なコード拡張性:
never型を用いた網羅性チェックにより、機能追加時の考慮漏れをコンパイラが自動で検出する。
Reactの useReducer のアクション設計や、ReduxのReducers、あるいはAPIレスポンスのモデリングにおいて、タグ付きユニオンはTypeScript開発の必須テクニックです。ぜひ日々の設計に取り入れてみてください。
