【入門編】関数引数における「Discriminated Unions」による状態管理の型安全化 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!フロントエンドからバックエンドまで、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 `

Loading…

`;

case ‘success’:
// このブロック内では、state は確実に ‘SuccessState’
// だから、data は optional ではなく、型安全に直接アクセスできる!
return `

Welcome, ${state.data.name} (ID: ${state.data.id})

`;

case ‘error’:
// このブロック内では、state は確実に ‘ErrorState’
return `

Error: ${state.error.message}

`;
}
}

どうですか?さっきまであった `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に毛が生えたもの」から、堅牢で美しい「型安全なシステム」へと劇的に進化します。

ぜひ、日々の開発のコードレビューや設計で使ってみてくださいね。それでは、次のステップでお会いしましょう!

タイトルとURLをコピーしました