状態遷移を「型」で封じ込める ― コンパイラが許容しない不正状態という名のバグ
シニアエンジニア諸氏なら一度は経験があるだろう。「なぜこのタイミングでこのデータが存在しているのか?」という、非同期イベントのレースコンディションに起因するランタイムの崩壊を。
多くの現場では、状態遷移の管理を `if` や `switch` で場当たり的に処理している。しかし、それは「防壁の隙間をパッチで埋める」ような行為だ。我々が扱うべきは、「遷移そのものを型レベルの制約としてコンパイラに刻み込み、不正な状態をプログラムの存在次元から消去する」というアプローチである。
今日は、Discriminated Unions(判別可能な共用体)を極限まで活用した、堅牢なステートマシン設計の真髄を解き明かす。
—
1. 状態の「不可能性」をコンパイル時に定義する
TypeScriptの型システムにおいて、共用体型は単なる OR ではない。それは「存在し得る全宇宙のサブセット」の定義だ。
// 遷移可能な状態を「判別可能な共用体」で厳格に定義する
type AppState =
| { status: ‘idle’ }
| { status: ‘loading’; startedAt: number }
| { status: ‘success’; data: unknown; fetchedAt: number }
| { status: ‘error’; error: Error };
この定義の肝は、`status` というタグが、「その状態に紐づくべき必要なデータ」を強制的にバインドしている点だ。`loading` なのに `data` にアクセスしようとすれば、TypeScriptコンパイラは即座にエラーを吐く。これはランタイムのメモリレイアウトを予測可能にする第一歩である。
2. 遷移関数における「全域性(Totality)」の強制
多くのバグは、遷移の「抜け漏れ」から生まれる。これを防ぐには、TypeScriptの `never` 型を最強の武器として使う。
function transition(state: AppState, action: Action): AppState {
switch (state.status) {
case ‘idle’:
if (action.type === ‘FETCH’) return { status: ‘loading’, startedAt: Date.now() };
return state;
case ‘loading’:
if (action.type === ‘RESOLVE’) return { status: ‘success’, data: action.payload, fetchedAt: Date.now() };
if (action.type === ‘REJECT’) return { status: ‘error’, error: action.error };
return state;
// ここで ‘success’ や ‘error’ のケースを忘れると、コンパイラが警告する
default:
// 排他的論理和の網羅性チェック
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}
この `_exhaustiveCheck` パターンは、コンパイラの静的解析能力を最大限に引き出す。もし将来、`AppState` に新たな状態が追加された場合、この `switch` 文が網羅的でなければ、コンパイルエラーによって「遷移ロジックの修正」を強制できる。これは、開発中のフィードバックループを最短化する極めて強力な防壁だ。
3. 非同期イベントループとキューの厳密な消費
現実のシステムでは、遷移は往々にして非同期に発生する。Node.jsのイベントループやブラウザのタスクキューにおいて、状態が「遷移の途中に更新される」ことを防ぐには、「状態更新関数をクロージャに閉じ込め、不変性(Immutability)を担保する」必要がある。
// 状態の更新は必ず「新しいオブジェクトの生成」で行う
// ミューテーションを許さないことが、予測可能な実行結果への唯一の道だ
const updateState = (prevState: AppState, newState: AppState): AppState => {
// ここでディープイコールやメモリのライフサイクルを考慮する
Object.freeze(newState);
return newState;
};
メモリレベルで見れば、オブジェクトを再生成することはコストに見えるかもしれない。しかし、現代のV8エンジンにおけるインラインキャッシュや隠しクラス(Hidden Classes)の最適化を考慮すれば、型の固定されたオブジェクトを新しく作るコストは、不整合な状態をデバッグするコストに比べれば微々たるものだ。
4. チーフアーキテクトからの提言
このパターンを実戦導入する上で、一つだけ注意してほしい。それは「状態遷移が複雑になりすぎた場合、型定義が爆発する」という点だ。
もし状態数が10を超え、遷移先が複雑に絡み合うなら、それはステートマシンではなく「ビジネスロジックの肥大化」が原因である。その際は、状態管理をコンポーネントやモジュール単位で分割し、「型によってカプセル化された小さなステートマシン」を階層的に組み合わせること。
結論として
我々エンジニアの仕事は、「動くものを作る」ことではない。「壊れないものを、壊れるはずがないと証明しながら作る」ことにある。
Discriminated Unionsは、単なる便利な機能ではない。それは、複雑怪奇な非同期処理の荒波に対して、「コンパイラという名の静的な審判」を現場に常駐させるための高度な防衛戦術なのだ。
コードを書くとき、自問してほしい。
「この遷移は、コンパイラによって物理的に不可能だと証明されているか?」
その答えが「Yes」であるとき、あなたのコードは初めて、大規模システムを支えるに足る信頼性を手に入れる。