状態遷移を「祈るな、型に語らせろ」:リテラル型ユニオンで構築する堅牢な有限状態マシン(FSM)
コードレビューをしていて、最も背筋が凍る瞬間のひとつがこれだ。
// どこにでもある「危険な」コンポーネントの状態管理
type UserState = {
status: ‘idle’ | ‘loading’ | ‘success’ | ‘error’;
data?: User;
error?: string;
};
function handleAction(state: UserState) {
// 「isLoadingのときはerrorはないはず…」というプログラマの「祈り」
if (state.status === ‘success’) {
console.log(state.data.name); // 厄介な ! (Non-null assertion) や Optional Chaining の乱用
}
}
フロントエンド開発、非同期API連携、複雑なウィザードUI。私たちは常に「状態(State)」と「イベント(Event)」の荒波に揉まれている。
上記のコードのように、フラグやオプショナルプロパティを寄せ集めた「巨大な単一の型」で状態を表現していないだろうか? その設計は、アプリケーションが複雑化するにつれて必ず破綻し、ランタイムエラーという名の爆弾を抱えることになる。
TypeScriptの型システムは、単なる「型のラベル貼り」ではない。コンパイル時における厳密なドメインモデルの構築ツールだ。
今回は、リテラル型ユニオンと「判別可能な共用体(Discriminated Unions)」を極限まで押し進め、「不正な状態遷移そのものをコンパイルエラーとして弾き返す」プロダクションコードの設計パターンを伝授する。
—
1. なぜ「フラグの寄せ集め」は破綻するのか?
まず、なぜ従来の設計がダメなのか、コンパイラの視点からロジカルに解剖しよう。
次のような「データフェッチ」の状態を考えてみる。
- `status: ‘loading’` のとき、`data` は存在すべきではない。
- `status: ‘success’` のとき、`data` は必ず存在しなければならない。
- `status: ‘error’` のとき、`error` は必ず存在しなければならない。
これを単一のオブジェクト型(すべてのプロパティをオプショナルにするアプローチ)で表現すると、型システムは「`status` が `success` なのに `data` が `undefined` である世界線」を合法とみなしてしまう。結果として、開発者はガード節や `as` キャスト、`!` 演算子で型システムを「黙らせる」ハメになる。
これはTypeScriptの恩恵を自ら捨てているに等しい。
2. 判別可能な共用体(Discriminated Unions)による状態のモデリング
解決策はシンプルだ。「あり得ない状態の組み合わせが存在できない型」を定義する。
ここで用いるのが、共通のタグラベル(通常は `status` や `type`)を持つリテラル型ユニオンだ。
以下のコードを見てほしい。これが、TypeScriptの型推論能力を最大限に引き出す状態定義の模範解答だ。
// — 1. 個別の状態を「排他的」に定義する —
type IdleState = {
readonly status: ‘IDLE’;
};
type LoadingState = {
readonly status: ‘LOADING’;
readonly progress: number; // ローディング進捗など
};
type SuccessState
readonly status: ‘SUCCESS’;
readonly data: T; // 成功時は必ずデータが存在する(undefined許容しない)
};
type ErrorState = {
readonly status: ‘ERROR’;
readonly error: Error; // エラー時は必ずErrorオブジェクトが存在する
};
// — 2. これらをユニオンで結合する(これがFSMの「状態空間」になる) —
export type AsyncState
| IdleState
| LoadingState
| SuccessState
| ErrorState;
この設計において、`AsyncState
—
3. 型安全な状態遷移エンジン(FSM)の実装
状態が定義できたら、次は「どう遷移するか」だ。
「どの状態から、どのイベントによって、どの状態へ遷移してよいか」というルール(遷移マトリクス)を型レベルで強制する。
実務でそのまま使える、堅牢なFSMハンドラーのプロダクションコードを示す。
// — イベントの定義 —
type FetchEvent =
| { type: ‘FETCH_START’ }
| { type: ‘FETCH_PROGRESS’; payload: number }
| { type: ‘FETCH_SUCCESS’; payload: User }
| { type: ‘FETCH_FAILURE’; error: Error }
| { type: ‘RESET’ };
// — 状態遷移関数(Reducer) —
function userStateReducer(currentState: AsyncState
switch (currentState.status) {
case ‘IDLE’:
switch (event.type) {
case ‘FETCH_START’:
return { status: ‘LOADING’, progress: 0 };
default:
return currentState; // 無効なイベントは状態を維持(または厳格にエラーを投げる)
}
case ‘LOADING’:
switch (event.type) {
case ‘FETCH_PROGRESS’:
return { status: ‘LOADING’, progress: event.payload };
case ‘FETCH_SUCCESS’:
return { status: ‘SUCCESS’, data: event.payload };
case ‘FETCH_FAILURE’:
return { status: ‘ERROR’, error: event.error };
case ‘RESET’:
return { status: ‘IDLE’ };
default:
return currentState;
}
case ‘SUCCESS’:
case ‘ERROR’:
// 終了状態(Terminal States)からの再挑戦
switch (event.type) {
case ‘FETCH_START’:
return { status: ‘LOADING’, progress: 0 };
case ‘RESET’:
return { status: ‘IDLE’ };
default:
return currentState;
}
// 網羅性チェック(Exhaustiveness Checking)
// 将来、新しい状態(例: ‘RETRYING’)が追加された際、ここを書き忘れるとコンパイルエラーになる
default:
const _exhaustiveCheck: never = currentState;
return _exhaustiveCheck;
}
}
チーフアーキテクトの視点:なぜこのコードが美しいのか?
1. 網羅性チェック(Exhaustiveness Checking)の極み
末尾の `default` 節における `const _exhaustiveCheck: never = currentState;` は、TypeScript界隈では定石とされるイディオムだ。もし `AsyncState` に新しい状態を追加し、スイッチ文のケースを書き忘れた場合、TypeScriptは `currentState` が `never` にならないことを検知し、ビルドを即座に失敗させる。ヒューマンエラーが入り込む余地を型システムが完全に根絶する。
2. イミュータビリティ(readonly)の徹底
状態オブジェクトのプロパティにすべて `readonly` を付与している。これにより、アプリケーションのどこかでうっかり `state.data = something` のような直接代入(ミューテーション)を行おうものなら、即座にコンパイルエラーとなる。状態は常に「新しく生成して置き換える」ことが強制される。
—
4. フロントエンドUIコンポーネントでの実践
この堅牢な型を、実際のReactコンポーネントやビュー層でどう扱うか。
余計な三項演算子や `if (state.data)` のチェックから解放された、極めてクリーンなコード記述が可能になる。
import React, { useReducer } from ‘react’;
export const UserProfileCard: React.FC = () => {
const [state, dispatch] = useReducer(userStateReducer, { status: ‘IDLE’ });
return (
{state.status === ‘IDLE’ && (
)}
{/ LOADING 状態 /}
{state.status === ‘LOADING’ && (
読み込み中… {state.progress}%
{/ state.progress は LoadingState に存在することが保証されている /}
)}
{/ SUCCESS 状態 /}
{state.status === ‘SUCCESS’ && (
{state.data.name}
{state.data.email}
{/
ここでは state.data が必ず存在するため、
state.data.name のように直感的にアクセスできる。
Optional Chaining (?.) や ! 演算子は一切不要。
/}
)}
{/ ERROR 状態 /}
{state.status === ‘ERROR’ && (
エラーが発生しました: {state.error.message}
{/ state.error は Error 型であることが保証されている /}
)}
);
};
JSX内での条件分岐において、`state.status` をチェックした瞬間に、対応するデータ構造へ自動的に型がナローイングされる。IDEの補完(IntelliSense)も完璧に効き、存在しないプロパティを誤って参照するバグは物理的に発生しなくなる。
—
5. パフォーマンスとコンパイル速度に関する注意点
最後に、シニアエンジニアとしてパフォーマンスとスケールの観点に言及しておこう。
- 型の肥大化とコンパイル時間:
複雑なFSMを何十個も巨大なユニオン型で組み上げると、TypeScriptの型チェッカー(TSServer)の推論コストが増大し、エディタの動作が重くなることがある。その場合は、状態ごとの型定義を適切にモジュール分割し、ジェネリクスを活用して型の再利用性を高めること。
- ランタイムのオーバーヘッド:
このパターンは純粋に「コンパイル時のみ」の型安全性を提供するものであり、実行時には余分なクラスインスタンスの生成や重いライブラリの処理を伴わない。プレーンなJavaScriptのオブジェクトとスイッチ文として動作するため、ランタイムのパフォーマンスは極めて軽量である。
—
結び:型とは「設計の意思表示」である
TypeScriptの型システムを「型エラーが出ないように辻褄を合わせるための面倒な作業」と捉えているうちは、この言語の真のポテンシャルを引き出せていない。
リテラル型ユニオンを用いた状態遷移の設計は、「このアプリケーションにおいて、何が起き得て、何が起き得ないのか」をコードの構造そのもので語るための最も強力な手法だ。
明日からのコードレビューで、フラグまみれの脆いコンポーネントを見つけたらこう言ってほしい。
―― 「その状態管理、型に語らせようか」 と。