こんにちは。テックリードの私だ。
君たちのチームでは、非同期APIのフェッチ状態や、複雑なマルチステップフォームの画面状態を管理する際、いまだにこんな「アンチパターン」を放置していないだろうか?
// ❌ 絶望的なアンチパターン
type BadState = {
status: ‘idle’ | ‘loading’ | ‘success’ | ‘error’;
data?: UserData;
error?: Error;
};
この型の何が問題か。`status` が `’success’` であるにもかかわらず `error` が存在し得たり、`’loading’` なのに `data` が残っているという、「論理的にあり得ない不正な状態(Impossible States)」を型システムが許容している点だ。これでは、コンポーネントのレンダリング時に防衛的コード(無駄な `if` 文の乱雑なネスト)を書くハメになり、保守性はドブに捨てることになる。
TypeScriptの型システムは、単なる「型のラベル貼り」ではない。ドメインのビジネスロジックをコンパイル時に強制するための論理エンジンだ。
今回は、リテラル型ユニオンと判別可能なユニオン(Discriminated Unions)を極限まで押し進め、「遷移不可能な状態を型レベルで物理的に排除するステートマシン設計」の実務解を伝授する。
—
1. 状態遷移の本質:直積型から直和型へのパラダイムシフト
まずアーキテクチャの根幹を確認する。上記の `BadState` のように、フラグやオプショナルプロパティを一つのオブジェクトに詰め込むアプローチは「直積型(Cartesian Product)」を生む。これは状態の数が増えるほど爆発的にバグの温床を増やす。
我々が目指すべきは「直和型(Tagged Union / Discriminated Union)」による表現だ。
「どの状態にいるか(Tag)」によって、「保持できるペイロード(Data)」を完全に分離する。
プロダクション品質のステートマシン実装
実務で即座に使える、非同期データフェッチを題材とした堅牢なステートマシンのコードを見てほしい。
/
- フェッチング・ステートマシンの型定義
/
export type FetchState
| { status: ‘IDLE’; data: null; error: null }
| { status: ‘LOADING’; data: T | null; error: null } // 楽観的UI更新を考慮して前回のdata保持を許容
| { status: ‘SUCCESS’; data: T; error: null }
| { status: ‘ERROR’; data: null; error: Error };
/
- 許可される状態遷移マトリクス(型レベルの制約)
- どの状態から、どの状態へ「のみ」移行できるかを定義
/
type ValidTransitions = {
IDLE: ‘LOADING’;
LOADING: ‘SUCCESS’ | ‘ERROR’ | ‘IDLE’; // キャンセルによるIDLE復帰も考慮
SUCCESS: ‘LOADING’ | ‘IDLE’;
ERROR: ‘LOADING’ | ‘IDLE’;
};
/
- 型安全な遷移関数
- 現在の状態と「目指す次の状態」を受け取り、コンパイル時に遷移の正当性を検証する
/
export function transition<
T,
CurrentStatus extends FetchState
NextStatus extends ValidTransitions[CurrentStatus]
>(
currentState: FetchState
nextStatus: NextStatus,
payload: { data?: T; error?: Error } = {}
): FetchState
// 実行時ガード(型システムをすり抜けた不正入力を防ぐ最終防衛ライン)
// ※ 通常、TypeScriptの型が正しく伝搬していれば、この内部ロジックは極めてシンプルになる
switch (nextStatus) {
case ‘IDLE’:
return { status: ‘IDLE’, data: null, error: null };
case ‘LOADING’:
return { status: ‘LOADING’, data: currentState.data, error: null };
case ‘SUCCESS’:
if (!payload.data) throw new Error(‘SUCCESS state requires data.’);
return { status: ‘SUCCESS’, data: payload.data, error: null };
case ‘ERROR’:
if (!payload.error) throw new Error(‘ERROR state requires an error object.’);
return { status: ‘ERROR’, data: null, error: payload.error };
default:
// 網羅性チェック(Exhaustiveness Checking)
const _exhaustiveCheck: never = nextStatus;
throw new Error(`Unsupported state transition to: ${_exhaustiveCheck}`);
}
}
—
2. この設計がもたらす圧倒的なメリット
① 遷移不可能のコンパイルエラー化
もし、開発者がうっかり `IDLE` 状態から直接 `SUCCESS` 状態へ遷移させようとした場合、TypeScriptコンパイラは即座に牙を剥く。
const initialState: FetchState
// ❌ コンパイルエラー!
// 型 ‘”SUCCESS”‘ は 型 ‘”LOADING”‘ の制約に割り当てられません。
const next = transition(initialState, ‘SUCCESS’, { data: userObject });
この瞬間、「仕様書を見ないと正しい遷移がわからない」という属人性がコードベースから消滅する。 型定義そのものが生きた仕様書となり、IDEの補完が正しいパスだけを開発者に提示する。
② 網羅的ハンドリング(Exhaustiveness Checking)の強制
UIコンポーネント側で状態を描画する際も、`switch` 文やパターンマッチ関数を書けば、TypeScriptがすべての状態を網羅しているかを静的に検証する。
function renderUI
switch (state.status) {
case ‘IDLE’:
return ;
case ‘LOADING’:
return
case ‘SUCCESS’:
// ここでは state.data が非nullであることが「型の世界で保証」されているため、
// 余計な optional chaining (?. ) や null チェックを書く必要がない
return
case ‘ERROR’:
// state.error は確実に Error 型
return
default:
// 将来ステータスが増えた際、ここを書き忘れるとコンパイルエラーになる
const _exhaustive: never = state;
return _exhaustive;
}
}
—
3. チーフアーキテクトからのパフォーマンスと実務の注意点
ここで、コンパイラ内部の挙動とパフォーマンスに言及しておこう。
1. ホモジニアスなプロパティ名の維持
判別可能なユニオンを作る際、すべてのオブジェクトで判別子(Discriminant)の名前を統一すること(上記の例では `status`)。異なるプロパティ名(例: `state` や `type` が混在)にすると、TypeScriptのコントロールフロー分析(Control Flow Analysis)のキャッシュ効率が落ち、大規模なコードベースにおいて型チェックの速度(tscのパフォーマンス)に悪影響を及ぼす。
2. 過剰な複雑化の抑止
すべてのコンポーネントでこの厳密なステートマシンを組む必要はない。ローカルなトグル(モーダルの開閉など)にはオーバースペックだ。だが、「外部API通信」「マルチステップフォーム」「認証セッション管理」といった、ビジネスロジックの根幹をなす領域においては、この設計を標準(Gold Standard)とせよ。
—
結び
優れたTypeScriptコードとは、「実行時エラーの可能性を、可能な限りコンパイル時の型エラーへとシフトさせたコード」に他ならない。
「動けばいい」という妥協を捨て、型システムを味方につけろ。コードレビューでこのステートマシンパターンが自然とチームメンバーから提案されるようになった時、そのプロジェクトの品質は次のステージへと到達しているはずだ。