関数引数におけるDiscriminated Unions:オプショナル属性の地獄を脱し、型レベルで不正な状態を絶つ設計パターン
コードレビューでよく目にする、一見すると親切で、その実バグの温床となっている関数シグネチャがある。
// 🛑 アンチパターン:不可能性を許容してしまっている関数設計
type FetchState = {
status: ‘idle’ | ‘loading’ | ‘success’ | ‘error’;
data?: UserData;
error?: Error;
retryCount?: number;
};
function handleStateChange(state: FetchState) { … }
一見すると何の変哲もない型定義に見えるかもしれない。しかし、この型はコンパイラに対して「`status: ‘loading’` でありながら `data` や `error` を同時に保持する状態」や、「`status: ‘success’` なのに `data` が `undefined` である状態」の存在を許してしまっている。
実務で発生する不具合の多くは、このような「ドメイン上はあり得ないはずのデータ構造」を型定義レベルで排除できていないことに起因する。
本稿では、関数引数における Discriminated Unions(可別判別ユニオン型) の真価を解き明かす。単なる記法テクニックにとどまらず、型論理の観点、TypeScriptコンパイラ(`checker.ts`)の評価メカニズム、そして実行時パフォーマンスまでを考慮した、プロダクションクオリティの設計原則を授ける。
—
1. なぜ「オプショナル引数の羅列」は破綻するのか?(直積 vs 直和)
なぜ先ほどの `FetchState` 型が危険なのか。型論理の視点から紐解こう。
型の世界において、オブジェクト型は各プロパティの型の「直積(Product Type)」として表現される。
オプショナルなプロパティ `data?: UserData` は `UserData | undefined` と等価であるため、取り得る状態空間は各プロパティの候補数の掛け算(積)で爆発的に増加する。
// FetchState が許容してしまう解釈可能な状態の数
status (4種) × data (2種: UserData | undefined) × error (2種: Error | undefined) × retryCount (2種: number | undefined)
= 32 通りの状態パターン
しかし、我々が実際にハンドリングしたい健全な状態は以下の4パターンのみのはずだ。
1. `idle`: 初期状態(データなし、エラーなし)
2. `loading`: 取得中(リトライ回数のみ存在する可能性あり)
3. `success`: 取得成功(必ず `data` が存在する)
4. `error`: 取得失敗(必ず `error` が存在する)
残り28通りの「不正な状態(Invalid States)」を型システムが許容しているため、関数内部では次のような不毛な実行時ガードを書かざるを得なくなる。
function handleStateChange(state: FetchState) {
if (state.status === ‘success’) {
// コンパイラは data が絶対に存在することを保証してくれないため、非オプショナルチェイニングやアサーションが必要になる
console.log(state.data!.name); // 💣 開発者の「思い込み」による非破壊アサーション。バグの引き金。
}
}
我々が目指すべきは、「不正な状態を表現不可能な構造にする(Make Invalid States Unrepresentable)」ことだ。これを実現するのが「直和(Sum Type)」であり、TypeScriptにおける Discriminated Unions である。
—
2. Discriminated Unions による関数の「不変条件」のエンコード
関数の引数に Discriminated Unions を適用することで、状態ごとの相関関係(不変条件: Invariants)を型レベルで固定化する。
判別子(Discriminant)として機能させるプロパティには、単一のリテラル型(文字列、数値、真偽値、Symbol等)を使用する。
// ✅ 堅牢な設計:各状態を分離し、直和(Sum Type)として定義する
export type FetchState =
| { readonly status: ‘idle’ }
| { readonly status: ‘loading’; readonly retryCount: number }
| { readonly status: ‘success’; readonly data: UserData; readonly fetchedAt: Date }
| { readonly status: ‘error’; readonly error: Error };
この定義により、状態空間は厳密に 1 + 1 + 1 + 1 = 4 通り に限定される。
`status` が `’success’` であれば、コンパイラは即座に Control Flow Analysis(流れ解析)を働かせ、そのブロック内で `data` および `fetchedAt` が確実にかつ安全に存在することを認識する。
—
3. 実務で即採用できるプロダクションコード例
以下は、フロントエンドでの非同期データフェッチおよびコンポーネント状態遷移、あるいはNode.jsでのWorker処理などでそのまま活用できる、極めて堅牢なステートマシン駆動型のコード例だ。
網羅性チェック(Exhaustiveness Check)を組み込み、将来的な状態追加時にもコンパイルエラーとして即座に検知できる設計にしてある。
// ============================================================================
// ドメイン型の定義
// ============================================================================
export type UserData = {
readonly id: string;
readonly name: string;
readonly email: string;
};
// Discriminated Union による状態カプセル化
export type AsyncState
| { readonly status: ‘idle’ }
| { readonly status: ‘pending’; readonly startTime: number }
| { readonly status: ‘fulfilled’; readonly data: T; readonly updatedAt: Date }
| { readonly status: ‘rejected’; readonly error: Error; readonly isRetryable: boolean };
// ============================================================================
// コンパイル時網羅性チェックのためのユーティリティ
// ============================================================================
/
- 到達不能なコードパスであることを型レベルで検証する。
- 新しい status が追加された際、reducers 内の switch 文でハンドリングが漏れていると
- コンパイルエラー(Argument of type ‘…’ is not assignable to parameter of type ‘never’)が発生する。
/
function assertUnreachable(x: never): never {
throw new Error(`[Fatal Architecture Error]: Unhandled discriminated union member: ${JSON.stringify(x)}`);
}
// ============================================================================
// ステートリデューサー/状態遷移関数
// ============================================================================
export type StateTransitionEvent
| { readonly type: ‘START’ }
| { readonly type: ‘RESOLVE’; readonly payload: T }
| { readonly type: ‘REJECT’; readonly error: Error; readonly isRetryable: boolean }
| { readonly type: ‘RESET’ };
/
- ディスパッチされたイベントと現在の状態を受け取り、厳密な型遷移を行う純粋関数
/
export function asyncStateReducer
currentState: AsyncState
event: StateTransitionEvent
): AsyncState
// イベントの判別子による分岐
switch (event.type) {
case ‘START’:
// どのような現在の状態からでも pending への遷移を許容
return {
status: ‘pending’,
startTime: Date.now(),
};
case ‘RESOLVE’:
// pending 以外の状態からの RESOLVE はロジックエラーとして弾くか、新状態を返す
return {
status: ‘fulfilled’,
data: event.payload,
updatedAt: new Date(),
};
case ‘REJECT’:
return {
status: ‘rejected’,
error: event.error,
isRetryable: event.isRetryable,
};
case ‘RESET’:
return { status: ‘idle’ };
default:
// ここでイベント型の網羅性を検証
return assertUnreachable(event);
}
}
// ============================================================================
// 状態に応じた処理(UI描画ロジックやAPIレスポンスハンドラー)
// ============================================================================
/
- 呼び出し側: Discriminated Union による狭められた型安全なロジック実行
/
export function renderAsyncUI(state: AsyncState
switch (state.status) {
case ‘idle’:
return ‘
‘;
case ‘pending’:
// state.startTime に安全にアクセス可能
const elapsed = Math.floor((Date.now() – state.startTime) / 1000);
return `
`;
case ‘fulfilled’:
// state.data および state.updatedAt が型レベルで 100% 保証される
// オプショナルチェイニング (?.) や アサーション (!) は一切不要
return `
${escapeHtml(state.data.name)}
Email: ${escapeHtml(state.data.email)}
最終更新: ${state.updatedAt.toISOString()}
`;
case ‘rejected’:
// state.error および state.isRetryable が安全に抽出可能
return `
エラーが発生しました: ${escapeHtml(state.error.message)}
${state.isRetryable ? ‘‘ : ”}
`;
default:
return assertUnreachable(state);
}
}
function escapeHtml(str: string): string {
return str.replace(/[&<>“‘]/g, (m) => ({ ‘&’: ‘&’, ‘<': '<', '>‘: ‘>’, ‘”‘: ‘"’, “‘”: ‘'’ }[m]!));
}
—
4. コンパイラ内部挙動とパフォーマンスの評価
コアコミッターの視点から、TypeScriptコンパイラ(`checker.ts`)が Discriminated Unions をどう処理しているか、そして大規模プロダクションコードでのビルドパフォーマンスにどう影響するかを解説する。
① コンパイラの判別メカニズム(`getPropertiesOfType` と `isTypeSubsetOf`)
TypeScriptが `switch (state.status)` を評価する際、内部では以下の処理が高速に実行されている。
1. 判別子の特定: Union構成要素のすべてに共通して存在するプロパティ(ここでは `status`)を検索。
2. リテラル型の分離: 各要素の `status` の型が Unit Type(文字列リテラルや数値リテラルなどの互いに素な型)であることを確認。
3. インデックスマップの作成: コンパイラ内部で `status` の値からUnionの各構成メンバへの内部 O(1) マップを作成する。
この内部処理のおかげで、構成要素が数十に及ぶ巨大な Union 型であっても、構造的型付け(Structural Typing)の再帰的な比較コストを支払うことなく、非常に高速に型の絞り込み(Narrowing)が完了する。
② パフォーマンスにおける「やってはいけない」アンチパターン
Discriminated Unions の性能を損なう典型的なアンチパターンが 「ネストした複雑な述語(Predicates)」 や 「判別子に対するジェネリック型の誤用」 だ。
// 🛑 コンパイルパフォーマンスを極度に悪化させる例
type BadUnion
| { meta: { config: { tag: ‘A’ } }; value: T }
| { meta: { config: { tag: ‘B’ } }; value: T };
判別子がネストした深い階層にある場合、コンパイラは `state.meta.config.tag` をたどる度にオブジェクトの評価と構造的同値性の検証を行わなければならず、型チェック時間(`tsc` の実行時間)が二次関数的に増加する。
判別子(Discriminants)は必ずルート直下の浅い階層に、単一のリテラルとして配置せよ。
—
5. テクニカルリードとしてコードレビューで伝えるべき指針
もしあなたのチームのメンバーが、冒頭のような「オプショナル属性の羅列による引数定義」を書いて提出してきたら、レビューコメントにはこう記すべきだ。
> レビューコメントの例:
> 「この関数引数の型定義は `status` とその他のプロパティの関係性が型レベルで結合されていないため、`status: ‘success’` なのに `data` が存在しないような『ドメイン上あり得ない不正な状態』をコンパイル時に検知できません。
>
> 判別子(`status`)を用いた Discriminated Unions へリファクタリングしてください。
> これにより、`status` の値に応じた型絞り込みが自動で機能し、不必要なアサーション(`!`)や冗長な `undefined` チェックを排除できます。また、将来的な状態追加時にも `assertUnreachable` による網羅性チェックで堅牢性を維持できます。」
まとめ
1. オプショナル引数の積(Product)を避け、Discriminated Unions の和(Sum)で表現せよ。
2. 不正な状態は「処理で弾く」のではなく「型レベルで表現不能」にせよ。
3. `never` 型を用いた網羅性チェック(Exhaustiveness Check)を徹底し、リファクタリングに耐えうるコードにせよ。
4. 判別子はオブジェクトのルート直下に配置し、TypeScriptコンパイラの最適化パスに乗せよ。
これらを徹底することが、変更に強く、バグを未然に防ぐ「本物のTypeScript開発」への第一歩である。