TypeScriptで「不正な状態」を撲滅する:判別可能な共用体による状態遷移マシンの実装
多くのフロントエンド開発現場で散見される、「状態の不整合」。例えば、`isLoading` が `true` なのに `data` が存在している、あるいはエラーが発生しているはずなのにUIがローディング状態のまま固まっている……といったバグです。
これらは、状態を独立した複数のフラグ(`boolean`)で管理しようとする設計上の罪が原因です。TypeScriptのコアメンバーとして断言しましょう。「状態はフラグではなく、型で語れ」。
今回は、判別可能な共用体(Discriminated Unions)を駆使し、コンパイル時に不正な遷移を物理的に不可能にする、堅牢なステート管理パターンを伝授します。
—
1. なぜ「フラグ管理」は破綻するのか
まず、典型的なアンチパターンを見てみましょう。
// 悪い例:状態が独立しているため、矛盾した状態が許容されてしまう
interface State {
isLoading: boolean;
data?: any;
error?: Error;
}
// これだと「読み込み中かつエラー」という物理的にあり得ない状態が型として通ってしまう
const invalidState: State = { isLoading: true, error: new Error(‘失敗’) };
このコードでは、`data` と `error` が同時に存在している可能性を常に考慮しなければならず、利用側で `if (state.data)` といった無駄なガード節が増殖します。これはTypeScriptの型システムへの冒涜です。
—
2. 判別可能な共用体による「排他的状態管理」
解決策はシンプルです。状態を「一つの型」として定義し、それらを `|`(共用体)で繋ぎます。ここで重要なのが、各状態を識別するための共通のキー(ディスクリミネータ)を持たせることです。
// 状態の定義:各状態において必要なプロパティだけを厳密に定義する
type DataState
| { status: ‘idle’ }
| { status: ‘loading’ }
| { status: ‘success’; data: T }
| { status: ‘error’; error: Error };
// 状態遷移の関数:コンパイラが残りのケースを強制するため、網羅性が担保される
function handleState
switch (state.status) {
case ‘idle’:
return ‘待機中…’;
case ‘loading’:
return ‘読み込み中…’;
case ‘success’:
return `データ: ${state.data}`; // ここでは確実に data にアクセス可能
case ‘error’:
return `エラー発生: ${state.error.message}`; // ここでは確実に error にアクセス可能
}
}
この設計の美しさは、「特定の状態にいない限り、そのデータにはアクセスできない」という制約を、コンパイラが強制してくれる点にあります。
—
3. 実践:Reducerを用いた堅牢な遷移制御
プロダクション環境では、状態遷移を「イベント」として定義し、Reducerで管理するのが最も保守性が高い手法です。
type Action =
| { type: ‘FETCH_START’ }
| { type: ‘FETCH_SUCCESS’; payload: string }
| { type: ‘FETCH_ERROR’; payload: Error };
function reducer(state: DataState
switch (action.type) {
case ‘FETCH_START’:
return { status: ‘loading’ };
case ‘FETCH_SUCCESS’:
return { status: ‘success’, data: action.payload };
case ‘FETCH_ERROR’:
return { status: ‘error’, error: action.payload };
default:
return state;
}
}
この設計のメリット
1. 網羅性チェック: `switch` 文で `default: assertUnreachable(state)` と記述すれば、新しい状態を追加した瞬間にコンパイルエラーになり、実装漏れを防げます。
2. IDEの強力な補完: `state.status === ‘success’` と判定した瞬間に、IDEは `data` プロパティの存在を認識します。
3. 副作用の排除: 状態遷移のロジックが純粋関数(Reducer)に閉じ込められているため、ユニットテストが極めて容易です。
—
4. パフォーマンスと考慮事項
- オブジェクトの生成コスト: 状態が変わるたびに新しいオブジェクトを生成しますが、現代のJSエンジン(V8など)では、この程度の生成コストは無視できる範囲です。むしろ、バグ修正コストやデバッグ時間を考えれば、これを選択しない理由はありません。
- 巨大な状態遷移: 状態が数十種類を超えるような極端に複雑なマシンになる場合は、`XState` のようなライブラリの採用を検討してください。しかし、日常的なAPI通信やUIの切り替えであれば、本記事のパターンで十分カバー可能です。
—
最後に:型を「制約」ではなく「武器」にする
TypeScriptの型システムは、単なるドキュメントではありません。あなたのコードが「どのような振る舞いを許容し、何を禁止するか」を記述する設計図そのものです。
「何ができるか」を記述するのではなく、「どのような不正も起こり得ない」という制約を記述すること。これこそが、大規模開発を破綻させないための、我々エンジニアの共通言語です。
さあ、今すぐプロジェクトの `boolean` フラグを消し去り、型安全な状態遷移へとリファクタリングを始めましょう。型が正しければ、バグは自ずと消えていくはずです。