「型を弄ぶな、型に語らせろ」:判別可能な共用体(Discriminated Unions)による堅牢な設計
コードレビューで私が最も頻繁に指摘し、そして最も修正に時間を割かせるのが、「曖昧なオブジェクト定義」だ。
「このプロパティは `loading` が `false` の時だけ存在するはずです」
「この `error` フィールドがあるときは `data` は `undefined` です」
…そんな「実装者の脳内ルール」に頼ったコードは、翌週にはバグの温床となり、一ヶ月後には誰も触りたがらないレガシーと化す。TypeScriptを使っていながら、コンパイラを味方につけず、ただの「注釈」として扱っている証拠だ。
真のプロフェッショナルは、「不正な状態を型システム上で表現不可能にする」。そのための最強の武器が、判別可能な共用体(Discriminated Unions)だ。
—
1. アンチパターン:オプショナル地獄の末路
まず、多くの現場で見かける「一見良さそうだが、実は最悪な設計」を見てみよう。
// ❌ 避けるべき設計:すべての状態を一つのインターフェースに詰め込む
interface FetchState {
isLoading: boolean;
data?: string[];
error?: Error;
}
function render(state: FetchState) {
if (!state.isLoading) {
// ここで data がある保証は? error がない保証は?
// コンパイラは state.data が undefined かもしれないと警告し続ける。
// 結局、!(非 null アサーション)や optional chaining で誤魔化すことになる。
console.log(state.data?.map(item => item.toUpperCase()));
}
}
この設計の罪は、「ロード中なのにデータが存在する」「データがあるのにエラーも存在する」といった矛盾した状態を許容している点にある。実行時の挙動を型が正しく記述できていないのだ。
—
2. 究極の設計:判別可能な共用体
これを TypeScript の核心である「型の絞り込み(Narrowing)」を活かした設計に書き換える。ポイントは、共通の「タグ(リテラル型)」を持たせ、状態ごとに型を完全に分離することだ。
// ✅ プロフェッショナルの設計:状態を完全に分離し、タグ(type)で識別する
type FetchState =
| { type: ‘loading’ }
| { type: ‘success’; data: string[]; updatedAt: Date }
| { type: ‘error’; error: Error };
/
- 1. 共通のプロパティ名(ここでは ‘type’)を持つ
- 2. そのプロパティは「文字列リテラル型」である
- 3. それらを Union(|)で結合する
/
3. 実践:`switch` 文による網羅性の担保
この設計の真価は、`switch` 文や `if` 文で評価した瞬間に発揮される。TypeScript のコンパイラは、タグをチェックした後のブロック内では、オブジェクトの型が 「その状態に固有のもの」 であることを完全に理解する。
function render(state: FetchState) {
switch (state.type) {
case ‘loading’:
return “Loading…”;
case ‘success’:
// このブロック内では、state は { type: ‘success’; data: string[]; … } として扱われる。
// data は確実に存在し、updatedAt にもアクセス可能。
return state.data.map(d => d.trim()).join(‘, ‘);
case ‘error’:
// state は自動的に error オブジェクトを持つ型に絞り込まれる。
return `Error: ${state.error.message}`;
}
}
—
4. 現場の極意:`assertNever` による網羅性チェック
「将来的に `type: ‘idle’` を追加したとき、既存の `render` 関数を修正し忘れたら?」という不安を抱くのがリードエンジニアだ。
TypeScript に 「すべてのケースを網羅していない場合にコンパイルエラーを出す」 仕組みを組み込もう。
/
- 網羅性チェックのためのユーティリティ
- 実行時には何もしないが、静的解析で「never」型であることを強制する
/
function assertNever(value: never): never {
throw new Error(`Unhandled discriminant: ${JSON.stringify(value)}`);
}
function render(state: FetchState) {
switch (state.type) {
case ‘loading’: return “Loading…”;
case ‘success’: return “Success”;
case ‘error’: return “Error”;
default:
// もし FetchState に新しい型が追加され、case が足りない場合、
// ここで「型 ‘NewType’ は ‘never’ に割り当てられません」とコンパイルエラーが出る。
return assertNever(state);
}
}
この手法を導入するだけで、大規模開発におけるリファクタリングの安全性は飛躍的に向上する。
—
5. API連携における応用:Branded Typesとの組み合わせ
APIから返ってくるデータが常にこの構造になっているとは限らない。しかし、フロントエンドの境界(APIクライアント層)でこの型に変換・正規化(Normalize)することで、コンポーネント側は一切の「矛盾」から解放される。
// APIの生レスポンスを判別可能な共用体に変換するファクトリ
const createFetchState = (response: any): FetchState => {
if (response.error) {
return { type: ‘error’, error: new Error(response.error) };
}
if (response.data) {
return { type: ‘success’, data: response.data, updatedAt: new Date() };
}
return { type: ‘loading’ };
};
—
6. パフォーマンスと設計の勘所
1. タグの名前は統一せよ: `type`, `kind`, `status` など、プロジェクト全体で統一しろ。混在は混乱の元だ。
2. ネストを恐れるな: 複雑なUI状態(例えば「編集モード」かつ「保存中」など)も、判別可能な共用体のネストで表現できる。
3. 実行時のオーバーヘッドはゼロ: TypeScriptの型システムはコンパイル時に消滅する。この「絞り込み」は実行時のコストを一切増やさず、コードの安全性だけを最大化する。
結言
TypeScript を書くということは、単に型アノテーションを付けることではない。「データが取り得る形を論理的に整理し、矛盾をコンパイルエラーとして炙り出す構造を作る」 ことだ。
もし君のコードに `if (obj.prop !== undefined)` が溢れているなら、それは設計の敗北だ。今すぐ「判別可能な共用体」を導入し、コンパイラを君の専属デバッガーへと昇格させたまえ。
それが、世界基準のアーキテクチャへの第一歩だ。