フロントエンドのコードレビューをしていて、最も頭痛がする瞬間は何か。それは、APIレスポンスや非同期の画面状態を表現する共用体(Union)のハンドリングにおいて、`if (res.status === ‘success’)` のような場当たり的な条件分岐が散見され、新しい状態が追加された瞬間にアプリ全体がサイレントに崩壊する爆弾を抱えている時だ。
「とりあえず `data?: any` にしておこう」「`err` がないから大丈夫だろう」。そんな甘えは、TypeScriptの型システムに対する冒涜であり、プロダクションの信頼性をドブに捨てる行為に等しい。
今回は、TypeScriptの型理論の真骨頂である「判別可能な共用体(Discriminated Unions)」と、コンパイラの静的解析能力を極限まで引き出す「網羅性チェック(Exhaustiveness Checking)」を組み合わせ、「不正な状態を型レベルで表現不可能な状態にする(Make illegal states unrepresentable)」ための実践的アーキテクチャを伝授する。
—
なぜ「インターフェースのオプショナルプロパティ地獄」は破綻するのか?
多くのジュニア〜ミドルクラスの開発者がやりがちなアンチパターンを見てみよう。
// ❌ 悪い例:すべてのプロパティがオプショナルで絡み合う「闇のオブジェクト」
interface BadApiState {
isLoading: boolean;
data?: UserData;
error?: Error;
errorCode?: string;
}
この型定義の何が地獄か?
「`isLoading: true` なのに `data` が存在する」「`error` はあるのに `errorCode` がない」「そもそも `isLoading: false` で `data` も `error` も undefined」といった、論理的にあり得ない不正な状態(Impossible State)が型の上で無限に許可されてしまう点だ。
結果として、コンポーネント側で以下のような地獄のガード節を書かざるを得なくなる。
// ❌ どこまでいってもバグの温床になるコンポーネント
function UserProfile({ state }: { state: BadApiState }) {
if (state.isLoading) {
return
}
// ここで本当に data が存在するか、TypeScriptは保証できない(オプショナルだから)
if (state.data) {
return
;
}
if (state.error) {
return
}
return <>>; // 「何も起きない」という名のバグ
}
このコードの最大の問題は、将来的に `isRefreshing` や `isRetrying` という新しい状態が追加されたとき、誰もコンポーネント側の修正を強制されないことだ。これが型安全の崩壊であり、バグの温床の正体である。
—
解決策:Type Alias による「判別可能な共用体」の構築
状態を表現するときは、インターフェースを拡張するのではなく、排他的な直和(Discriminated Union)としてモデル化する。ここで `type` キーワードによる型エイリアスが真価を発揮する。
以下のプロダクションコードを見てほしい。非同期APIのライフサイクル(Idle, Loading, Success, Failure)を完全に型で封じ込めた実例だ。
// ==========================================
// 1. ドメインモデルの定義
// ==========================================
type User = {
id: string;
name: string;
email: string;
};
// ==========================================
// 2. 判別可能な共用体(Discriminated Union)の定義
// 共通のタグプロパティ(ここでは ‘status’)を持つことで、
// TypeScriptの制御フロー分析(Control Flow Analysis)が劇的に機能する。
// ==========================================
type AsyncState
| { status: ‘IDLE’ }
| { status: ‘LOADING’; progress?: number } // ローディング中のみ進捗度を持つ許容
| { status: ‘SUCCESS’; data: T; fetchedAt: number }
| { status: ‘ERROR’; error: Error; code: number };
// ユーザー取得用の状態型エイリアス
type UserState = AsyncState
このモデルが優れている理由
1. タグ(Discriminant)の存在: すべての型に `status` というリテラル型プロパティが存在する。これにより、TypeScriptは `status` を見た瞬間に他のプロパティの形を完全に特定できる。
2. 不正な状態の排除: `status: ‘SUCCESS’` のときは、必ず `data` と `fetchedAt` の存在が型レベルで保証される。オプショナルにする必要すらない。
—
網羅性チェック(Exhaustiveness Checking)でコンパイラを調教する
状態を共用体で定義したら、次はその状態をハンドリングするUI層やビジネスロジック層だ。ここで `switch` 文と `never` 型を組み合わせた網羅性チェックを導入する。
以下のヘルパー関数(あるいは `switch` 文)を実装してほしい。
// ==========================================
// 3. 網羅性チェック用のアサーション関数
// ==========================================
function assertNever(x: never): never {
throw new Error(`Unexpected object: ${JSON.stringify(x)}`);
}
// ==========================================
// 4. 安全なレンダリングコンポーネント
// ==========================================
export function UserProfileViewer({ state }: { state: UserState }) {
switch (state.status) {
case ‘IDLE’:
return
;
case ‘LOADING’:
// state.progress は LOADING の時だけに絞り込まれている
return
case ‘SUCCESS’:
// state.data や state.fetchedAt に安全にアクセスできる
return (
{state.data.name}
Email: {state.data.email}
取得時刻: {new Date(state.fetchedAt).toLocaleTimeString()}
);
case ‘ERROR’:
// state.error, state.code が保証される
return
;
default:
// 【極めて重要】
// ここに到達するということは、AsyncState に新しいステータスが追加されたにもかかわらず、
// この switch 文でハンドリングし忘れていることを意味する。
// TypeScriptのコンパイラはここでエラー(Type ‘…’ is not assignable to type ‘never’)を吐き出す。
return assertNever(state);
}
}
コンパイラはどう評価しているか?
もし将来、プロダクトの要件変更により `AsyncState` に `{ status: ‘REFRESHING’; data: T }` という新しい状態を追加したとする。
その瞬間、`UserProfileViewer` の `switch` 文で `REFRESHING` ケースを書き忘れていると、TypeScriptのコンパイラは `default` ブロックの `state` の型がもはや `never` ではなく、余った `’REFRESHING’` 型になっていることを検知し、ビルドエラー(型エラー)を発生させる。
> 「新機能を追加したのに、網羅性チェックのおかげでハンドリング漏れがビルド時に100%検知できた」
これこそが、シニアエンジニアが設計すべき堅牢なフロントエンド・アーキテクチャである。テストコードを書く以前に、型システム自体がテストの役割を果たすのだ。
—
パフォーマンス上の注意点:V8エンジンとオブジェクトシェイプ
最後に、アーキテクチャの裏側にある「実行時のパフォーマンス」についても触れておこう。型はコンパイル後に消えるが、生成されるオブジェクトの構造はJavaScriptのエンジン(V8など)に直接影響を与える。
判別可能な共用体を使う際、以下の点に注意せよ。
1. Hidden Class(隠しクラス)の最適化
V8エンジンは、同じプロパティ構造を持つオブジェクトに対して同じ Hidden Class(形状)を割り当て、プロパティアクセスのインラインキャッシュ最適化を行う。共用体であっても、タグとなるプロパティ(例: `status`)をオブジェクトの先頭(あるいは同じキー順序)で定義することを意識すると、JITコンパイラがオブジェクトの分岐を高速に処理しやすくなる。
2. 過剰なジェネリクス階層の回避
`AsyncState
—
チーフアーキテクトからの総括
「動けばいい」というコードは、プロトタイプ作成の数日間しか価値を持たない。プロダクションコードにおいて最も価値があるのは、「変更に強く、バグを物理的に侵入させない構造」である。
今回解説した `Type Alias` による判別可能な共用体と網羅性チェックのパターンは、非同期APIだけでなく、複雑なフォームの状態管理、マルチステップのウィザードUI、WebSocketのイベントハンドリングなど、フロントエンドのあらゆる「状態」を支配する最強の武器となる。
明日からのコードレビューで、オプショナルプロパティだらけの脆弱なコードを見かけたら、こう言ってこの記事を突きつけてやってほしい。
「おい、その状態、型で完全に絞り込めるか?」 と。