こんにちは!TypeScriptの型システムの世界へようこそ。
フロントエンドからバックエンドまで、型安全なコードを書く楽しさに魅せられていく時期ですね。
今回は、実務の現場で「おっ、やるな!」と周囲をうならせること間違いなしのテクニック、関数引数における「Discriminated Unions(判別可能なunion型)」による状態遷移の型安全化についてお話しします。
ここをクリアすれば、複雑なロジックを安全に組み立てる基礎がバッチリ身につきますよ。ぜひ最後までお付き合いくださいね。
—
1. なぜ「普通の型定義」だと限界が来るのか?
アプリケーションを作っていると、「ローディング中」「成功時」「エラー時」といった、状態によって持っているデータが全く異なる状況に頻繁に出会いますよね。
例えば、APIからユーザーデータを取得して画面に表示する関数を、次のような「甘い型」で書いてしまったことはありませんか?
// 良くある、ちょっと危うい型定義
type ApiState = {
status: ‘loading’ | ‘success’ | ‘error’;
data?: { id: string; name: string }; // 成功したときだけ欲しい
error?: Error; // エラーのときだけ欲しい
};
function handleResponse(state: ApiState) {
if (state.status === ‘success’) {
// 成功したんだから data は絶対にあるはず……!
console.log(state.data.name); // 厄介なエラー:Object is possibly ‘undefined’.
}
}
「あれ? `status` が `’success’` なんだから、`data` は存在しているはずなのに、なんでTypeScriptは怒るの?」ってイライラした経験、ありませんか?
TypeScriptのコンパイラは非常に正直です。上記の書き方だと、`status` が `’success’` であっても、「もしかしたら `data` が `undefined` のままかもしれない」という可能性を考慮し続けなければなりません。これが原因で、現場では不要な `if (state.data)` のような冗長なガードが量産されてしまいます。
—
2. 救世主:「Discriminated Unions(判別可能なUnion型)」とは?
この問題を鮮やかに解決するのが、Discriminated Unions(判別可能なユニオン型)です。
考え方はとてもシンプル。
すべての状態を表す型の中に、「お互いを識別するための共通のプロパティ(タグ)」を必ず持たせるだけです。一般的には `status` や `type` という名前が使われます。
図解的にイメージしてみましょう。
[ApiState ユニオン型]
┣ 1. Loading状態: { status: ‘loading’ }
┣ 2. Success状態: { status: ‘success’, data: User }
┗ 3. Error状態: { status: ‘error’, error: Error }
TypeScriptは、この共通のタグ(`status`)をチェックした瞬間に、「あ、今の状態はこれだな!」と推論し、他の状態のプロパティをスパッと忘れ(除外して)、その状態に固有のプロパティだけアクセス可能にしてくれます。これを専門用語で「Narrowing(型の絞り込み)」と呼びます。
—
3. 実践!状態遷移を完全に型安全にするコード
それでは、実際にコードを書いてその強力さを体験してみましょう。ここでは「注文処理(Order)」の状態遷移を例に取ります。
// ① 各状態を独立したオブジェクト型(インターフェース)として定義する
type IdleOrder = {
status: ‘IDLE’;
};
type ProcessingOrder = {
status: ‘PROCESSING’;
transactionId: string; // 処理中には必ずトランザクションIDがある
};
type CompletedOrder = {
status: ‘COMPLETED’;
transactionId: string;
receiptUrl: string; // 完了時には必ずレシートURLがある
};
type FailedOrder = {
status: ‘FAILED’;
errorCode: number; // 失敗時には必ずエラーコードがある
};
// ② これらをユニオン型(合体)でまとめる。これが Discriminated Unions です!
type OrderState = IdleOrder | ProcessingOrder | CompletedOrder | FailedOrder;
// ③ 状態を受け取る関数を定義する
function processOrderStep(state: OrderState) {
// ここで switch 文などでタグ(status)をチェックします
switch (state.status) {
case ‘IDLE’:
console.log(‘注文を開始します…’);
// state.transactionId と書くと、TypeScriptは即座にコンパイルエラーを出します!
break;
case ‘PROCESSING’:
// TypeScriptは、このブロックの中では state が ProcessingOrder であると断定します
console.log(`処理中です。トランザクションID: ${state.transactionId}`);
break;
case ‘COMPLETED’:
// ここでは COMPLETED 専用のプロパティに安全にアクセスできます
console.log(`完了しました!レシート: ${state.receiptUrl}`);
break;
case ‘FAILED’:
console.error(`エラーが発生しました。コード: ${state.errorCode}`);
break;
default:
// 【超重要】網羅性チェック (Exhaustiveness Check)
// 将来新しい状態(例: CanceledOrder)が追加されたとき、
// ここを書き忘れるとコンパイルエラーにしてくれる最強のパターンです。
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
このコードの何がスゴいのか?
1. 存在しないプロパティへのアクセスをコンパイル時に完全に阻止
`IDLE` の状態のときに `state.receiptUrl` のような存在しないプロパティを書こうものなら、TypeScriptが実行する前に赤色の波線で教えてくれます。
2. オプショナル(`?`)の乱用から解放される
「もしかしたら入っているかもしれないプロパティ」ではなく、「この状態なら確実に存在するプロパティ」として定義できるため、コードの意図が明確になります。
—
4. 陥りやすい文法エラーと注意点
初心者の頃によくやってしまう罠をいくつかご紹介しておきますね。
罠1: タグのスペルミスや型の不一致
タグに使うプロパティ名(例: `status`)や、その値(例: `’COMPLETED’`)は、大文字・小文字を含めて厳密に一致させる必要があります。ここが揺らいでいると、TypeScriptは別の型と誤認して絞り込みに失敗します。共通のタグ名はプロジェクト内でルール化しておくと安心ですね。
罠2: `switch` 文や `if` 文の網羅性チェックを忘れる
先ほどのコードの `default:` 部分にある `const exhaustiveCheck: never = state;` は、チーフアーキテクトである私も好んで使うイディオムです。
もし将来、新しい状態(例: `Canceled`)が `OrderState` に追加されたとき、`switch` 文に `case ‘CANCELED’` を書き忘れると、`state` が `never` 型に代入できなくなるため、「新しい状態の処理が実装されていませんよ!」とコンパイラが教えてくれるようになります。保守性が劇的に向上しますよ。
—
まとめ
いかがだったでしょうか?
Discriminated Unionsを使いこなせるようになると、複雑な非同期処理や画面の状態管理、APIのレスポンスハンドリングが驚くほど安全でエレガントになります。
「状態が変われば、データの形も変わる。だから型も完全に分ける」
この原則を頭の片隅に置いておくだけで、あなたの書くTypeScriptコードの信頼性は一段と跳ね上がります。
ぜひ明日の開発から、あちこちで使ってみてくださいね。それでは、また次回の知見でお会いしましょう!