コードレビューをしていて、最もエンジニアの「思考の浅さ」が露呈する瞬間子を見ているか?
それは、複数の異なる非同期状態(ローディング、成功、エラー)を持つコンポーネントやAPIハンドラーを受け取る関数で、以下のような「とりあえずオプショナルにしておいた泥臭い型」を書いている時だ。
// ❌ どこがダメか、秒で分かるか?
type BadState = {
status: ‘loading’ | ‘success’ | ‘error’;
data?: User[]; // success の時以外も存在しうる(undefinedチェック地獄)
error?: Error; // error の時以外も存在しうる
progress?: number; // loading の時以外も存在しうる
};
このアンチパターンは、「存在してはならない状態の組み合わせ(Invalid States)」を型レベルで許容している。その結果、コンポーネント内部や関数の実装内で、不毛な `if (state.status === ‘success’ && state.data)` のような冗長なガードや、あり得ない `undefined` に対する非nullアサーション(`!`)が蔓延することになる。
プロのフロントエンドアーキテクトであれば、Discriminated Unions(判別可能なUnion型)を駆使し、「不正な状態を型レベルで表現不可能な状態(Make Impossible States Impossible)」に昇華させなければならない。
今回は、実務の最前線で即座に使える、極限まで堅牢な状態管理の型設計を伝授する。
—
1. なぜ Discriminated Unions なのか?(コンパイラの型評価の裏側)
TypeScriptのコンパイラは、Union型の中に「共通の単一リテラル型を持つプロパティ(これを一般に Discriminant / タグ と呼ぶ)」が存在する場合、制御フロー分析(Control Flow Analysis)によって自動的に型を絞り込む(Narrowing)。
type State =
| { status: ‘IDLE’ }
| { status: ‘LOADING’; progress: number }
| { status: ‘SUCCESS’; data: User[] }
| { status: ‘ERROR’; error: Error };
ここで `status` こが Discriminant だ。
コンパイラは `if (state.status === ‘SUCCESS’)` という分岐に遭遇した瞬間、unionの他のメンバーを捨て、`state` が `{ status: ‘SUCCESS’; data: User[] }` であると厳密に断定する。これにより、`state.data` にアクセスする際の `undefined` チェックは完全に不要となる。
—
2. 実践:プロダクション品質の非同期状態管理ハンドラー
では、実務のフロントエンド開発、例えば複雑なダッシュボードのデータフェッチとUIレンダリングを制御する関数を例に、完璧な型設計を見ていこう。
以下のコードは、単に動くだけでなく、コンパイラの恩恵を最大化し、メンテナンス時のバグをゼロにするプロダクションコードだ。
/
- ユーザー情報のエンティティ
/
type User = {
id: string;
name: string;
email: string;
};
/
- 1. 状態をDiscriminated Unionsで完全に分離する
- 各状態に必要なプロパティ以外は一切持たせない。これが鉄則。
/
type AsyncState
| { readonly status: ‘idle’ }
| { readonly status: ‘loading’; readonly progress?: number }
| { readonly status: ‘success’; readonly data: T; readonly fetchedAt: Date }
| { readonly status: ‘error’; readonly error: Readonly
/
- 2. 状態に応じたUIレンダリングを強制する純粋関数
- Switch文の網羅性チェック(Exhaustive Check)を利かせることで、
- 新しい状態を追加した際にハンドリング漏れをコンパイルエラーで検知できる。
/
function renderDashboardContent
state: AsyncState
renderData: (data: T, fetchedAt: Date) => string,
renderError: (error: Error, canRetry: boolean) => string
): string {
switch (state.status) {
case ‘idle’:
return ‘
‘;
case ‘loading’:
// loadingの時は progress に安全にアクセス可能
const percent = state.progress ?? 0;
return `
`;
case ‘success’:
// success の時は data と fetchedAt に「確実に」アクセスできる
// オプショナルチェーニングや undefined ガードは不要
return renderData(state.data, state.fetchedAt);
case ‘error’:
// error の時は error と retryCount にアクセス可能
const canRetry = state.retryCount < 3;
return renderError(state.error as Error, canRetry);
default:
/
- 【最重要テクニック】Exhaustive Check (網羅性チェック)
- TypeScriptの never 型を利用し、将来 AsyncState に新しいステート(例: ‘refetching’)が
- 追加されたのに switch の case を書き忘れた場合、ここで必ずコンパイルエラーを発生させる。
/
const _exhaustiveCheck: never = state;
throw new Error(`Unhandled state: ${JSON.stringify(_exhaustiveCheck)}`);
}
}
—
3. この設計がもたらす圧倒的なアドバンテージ
① 認知負荷の極小化(Cognitive Load Reduction)
開発者は、特定のステート(例えば `success`)を扱っている時、他のステート(`error` や `loading`)のプロパティの存在を気にする必要が一切ない。IDEの補完(IntelliSense)には、そのステートで許可されたプロパティしか表示されないため、迷いようがない。
② 不正な状態の混入阻止(Immutability & readonly)
型定義に `readonly` を付与し、状態オブジェクト自体をイミュータブルに扱うことで、意図しないプロパティの書き換えによるバグをコンパイル時にシャットアウトする。
③ 拡張に対する閉鎖性・修正に対する開放性(OCP)
もし将来、要件変更で「キャッシュからの復元状態(`cached`)」を追加することになった場合、`AsyncState` に型を追加し、`switch` 文に `case ‘cached’:` を書き忘れると、`never` 型への代入エラーによって即座にコンパイラが教えてくれる。「コードの変更漏れによる本番障害」を完全に予防できる。
—
4. チーフアーキテクトからの実践的アドバイス
多くのジュニア〜ミドルクラスのエンジニアは、次のような「中途半端な共通化」をしがちだ。
// ❌ アンチパターン:全部入りインターフェース
interface State
isLoading: boolean;
data: T | null;
error: Error | null;
}
この設計だと、`isLoading` が `true` なのに `data` が存在するような、「ロード中なのに前回のデータが表示され続ける」というUX上のバグを型レベルで防げない。フラグの組み合わせが爆発的に増え、手動でバリデーションを書く羽目になる。
状態は「足し算(フラグの組み合わせ)」で表現するな。「排他的な選択(Union)」で表現しろ。
非同期処理、フォームの状態管理(未入力、編集中、バリデーションエラー、送信中)、マルチステップウィザードの遷移など、フロントエンドの複雑性の多くは Discriminated Unions を正しく適用するだけで美しく氷解する。
今日のレビューから、あなたのチームの `status?: string` や `data?: any` をすべて駆逐し、型安全な極上のコードベースへと導いてほしい。