こんにちは!フロントエンドからバックエンドまで、TypeScriptの荒波を一緒に航海するシニアアーキテクトの私です。
TypeScriptを書き始めの頃、「複数の状態を持つ複雑なデータを関数にどう渡すべきか」という壁にぶつかったことはありませんか?
例えば、APIから取得したデータが「読み込み中」「成功」「エラー」という異なる顔(状態)を持っているとき、すべてのプロパティを1つのオブジェクトに詰め込もうとして、型定義がカオスになってしまった……なんて経験、きっとありますよね。
今回は、そんなモヤモヤを氷解させ、「ここをクリアすれば、TypeScriptの基本はバッチリマスターできますよ!」と胸を張って言える強力な武器、Discriminated Unions(判別可能なユニオン型)の世界へあなたをご案内します。
コンパイラが私たちの意図を完璧に理解し、間違ったコードをビシッと弾いてくれる心地よさを、一緒に体感していきましょう!
—
1. 散らかったコードが引き起こす「型安全の崩壊」
まずは、よくある「イマイチな状態管理」から見てみましょう。
Web APIからユーザー情報を取得する処理をイメージしてください。データは「ローディング中」「成功」「失敗」の3つの状態があり得ます。
これを、素朴に1つの型で表現しようとすると、ついこう書きたくなりますよね。
// ❌ 避けるべきアンチパターンな型定義
type ApiState = {
status: ‘loading’ | ‘success’ | ‘error’;
data?: { id: number; name: string }; // 成功したときだけある
error?: Error; // 失敗したときだけある
};
この型定義、一見すると問題なさに思えますが、関数内で使うときに大きな罠が潜んでいます。
function handleApiResponse(state: ApiState) {
if (state.status === ‘success’) {
// コンパイラの視点:
// 「statusが ‘success’ だって? でも data は optional(?) だから、undefined かもしれないよ!」
console.log(state.data.name); // 💥 怒られる:Object is possibly ‘undefined’.
}
}
TypeScriptのコンパイラは非常に慎重です。「`status === ‘success’` だからといって、`data` が必ず存在保証されているわけではない(オプショナルなままだ)」と判断するため、エラーを出してしまいます。
かといって、ここで `state.data!.name` と非nullアサージョン(感嘆符)を使うのは、TypeScriptの型安全性を自らドブに捨てるようなもの。避けるべきです。
—
2. 救世主:「Discriminated Unions(判別可能なユニオン型)」とは?
ここで登場するのが、Discriminated Unionsです。
考え方はとてもシンプル。「すべての状態をひとまとめにするのではなく、状態ごとに独立した型を作り、それらを `|`(ユニオン型)で束ねる」のです。
そして、それぞれの型が共通して持つ「見分け用のタグ(Discriminant)」を持たせます。今回の場合は `status` プロパティがその「タグ」になります。
図解的に構造を見てみましょう。
[ ApiState ユニオン型 ]
┣ 1. LoadingState ( status: ‘loading’ )
┣ 2. SuccessState ( status: ‘success’, data: User )
┗ 3. ErrorState ( status: ‘error’, error: Error )
これをTypeScriptのコードに落とし込んでみます。
// ✅ 非常に美しいDiscriminated Unionsの定義
type LoadingState = {
status: ‘loading’;
};
type SuccessState = {
status: ‘success’;
data: { id: number; name: string }; // success の世界線では data は絶対に存在する!
};
type ErrorState = {
status: ‘error’;
error: Error; // error の世界線では error は絶対に存在する!
};
// これらをユニオン型で結合する
type ApiState = LoadingState | SuccessState | ErrorState;
—
3. 関数引数での実践:コンパイラが未来を予測する「コントロールフロー分析」
では、この `ApiState` を引数に取る関数を作ってみましょう。
ここに、TypeScriptの真骨頂であるコントロールフロー分析(Control Flow Analysis)の魔法がかかります。
function renderUi(state: ApiState) {
// ここで state.status をチェックする
switch (state.status) {
case ‘loading’:
// TypeScriptは賢いので、このブロック内では state が ‘LoadingState’ だと自動で絞り込む(Narrowing)
return `
`;
case ‘success’:
// このブロック内では、state は確実に ‘SuccessState’
// だから、data は optional ではなく、型安全に直接アクセスできる!
return `
`;
case ‘error’:
// このブロック内では、state は確実に ‘ErrorState’
return `
`;
}
}
どうですか?さっきまであった `state.data.name` でのコンパイルエラーが嘘のように消え、キャスト(型変換)も一切なしで安全にプロパティにアクセスできていますよね。
これが、Discriminated Unionsを使った状態管理の圧倒的なパワーです。
—
4. 現場で役立つ!さらに一歩進んだ応用と「網羅性チェック」
実際の開発現場では、将来的に「再試行中(retrying)」といった新しい状態が追加されることがよくあります。
そんなとき、`switch` 文の書き忘れを防ぐためのテクニックとして、網羅性チェック(Exhaustiveness Checking) を覚えておきましょう。
TypeScriptの `never` 型を利用します。
function assertNever(x: never): never {
data: x; // ここに到達してしまったらコンパイルエラーになる
throw new Error(`予期しない状態です: ${JSON.stringify(x)}`);
}
function processState(state: ApiState) {
switch (state.status) {
case ‘loading’:
return ‘処理中…’;
case ‘success’:
return ‘成功!’;
case ‘error’:
return ‘失敗…’;
default:
// もし将来、新しい状態(例: ‘retrying’)が追加され、
// この switch 文でハンドリングし忘れた場合、
// ここの state は ‘never’ ではなくなってしまうため、
// コンパイラが「ここに到達し得るよ!」と教えてくれる!
return assertNever(state);
}
}
この `assertNever` パターンを仕込んでおけば、チーム開発で誰かが新しい状態を追加したときに、対応漏れを防ぐ強力なセーフティネットになります。
—
まとめ
今回は、関数引数における Discriminated Unions による状態管理の型安全化について解説しました。
- オプショナルだらけの「何でも入り型」を作るのはやめよう
- 状態ごとに独立した型を作り、共通の「タグ(Discriminant)」でユニオンしよう
- `switch` や `if` の分岐によって、TypeScriptが自動で型を絞り込んでくれる(Narrowing)
- `never` を使った網羅性チェックで、将来の改修漏れをも防ごう
ここをしっかりとクリアできれば、あなたの書くTypeScriptコードは、単なる「JavaScriptに毛が生えたもの」から、堅牢で美しい「型安全なシステム」へと劇的に進化します。
ぜひ、日々の開発のコードレビューや設計で使ってみてくださいね。それでは、次のステップでお会いしましょう!