はじめに
この投稿は、TypeScript学習者が非同期UIの状態表現について、判別可能なユニオン型(Discriminated Union)の仕組みを自身の理解のために書いています。
API通信の結果を画面に反映するとき、状態はだいたい「読み込み中」「成功」「失敗」のいずれかになります。TypeScriptでは、statusという共通の目印(判別子)を付けて状態を分けることで、if文の中だけ安全にdataなどのプロパティへアクセスできます。
type SafeState =
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; errorMessage: string };
function handle(state: SafeState) {
if (state.status === "success") {
console.log(state.data.toUpperCase()); // ここだけdataに触れる
}
}
今回は、boolean + optionalの設計と比較しながら、判別可能なユニオン型が型の絞り込み(Type Narrowing)をどう支えるかを見ていきます。
1. 比較:boolean管理と判別可能なユニオン型
まず、不具合を生み出しやすい「悪い型設計」の例として、状態ごとにbooleanやoptionalプロパティをバラバラに持つ設計を見てみます。
// ①boolean + optionalで状態を表す場合(アンチパターン)
type BadState = {
isLoading: boolean;
isError: boolean;
data?: string; // 成功時しか使わない
errorMessage?: string; // エラー時しか使わない
};
// 型の制約が緩いため、現実にはあり得ない矛盾した状態を作れてしまう
const broken1: BadState = {
isLoading: true, // ロード中なのに
isError: true, // エラーも起きていて
data: "hello" // 成功データもある状態
};
function handleBadState(state: BadState) {
if (state.isLoading) {
// ロード中なのでデータは存在しない(undefinedの)可能性があるが、
// data?(optional)になっているため、TypeScriptはアクセスを警告してくれない
console.log(state.data);
}
}
data?のようにoptionalにしておくと、ロード中であってもstate.dataへのアクセス自体はコンパイルエラーになりません。実行時にundefinedになってクラッシュするリスクがあっても、TypeScriptは止めてくれません。また、変数broken1のように「矛盾したデータ」を許容してしまう点もこの設計の問題です。
次に、同じ3状態を判別可能なユニオン型で表します。
// ②statusという判別子で状態を分ける場合
type SafeState =
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; errorMessage: string };
// コンパイルエラー:Object literal may only specify known properties, and 'data' does not exist in type '{ status: "loading"; }'.
// loading状態には存在しないプロパティ(data)を持たせようとすると、即座にエラーで弾かれます。
// const broken2: SafeState = {
// status: "loading",
// data: "hello"
// };
SafeStateでは「ロード中だけどデータもある」のような矛盾した状態(broken2)を作ろうとした時点で、TypeScriptが即座にコンパイルエラーを出してくれます。状態ごとに持てるプロパティが型レベルで厳格に分かれているため、あり得ないデータがシステムに入り込むのを未然に防げます。
2. 仕組み:判別子statusで状態を分ける
判別可能なユニオン型は、|でつないだ複数のオブジェクト型を1つの型として扱う書き方です。各メンバーに共通のプロパティ(ここではstatus)があり、その値によって「今どの状態か」をTypeScriptが識別します。
type SafeState =
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; errorMessage: string };
function handleSafeState(state: SafeState) {
// ifの外では、まだどの状態か分からないためdataに触れません
// console.log(state.data); // コンパイルエラー
if (state.status === "success") {
// このifブロック内の「state」にホバーすると型が確定しています
// (parameter) state: { status: "success"; data: string; }
console.log(state.data.toUpperCase());
} else if (state.status === "error") {
// 条件式の state.status にホバーすると、すでに success が除外されています
// (property) status: "loading" | "error"
// このelse ifブロック内の「state」にホバーすると型が確定しています
// (parameter) state: { status: "error"; errorMessage: string; }
console.log(state.errorMessage);
}
}
handleSafeState関数にstateを渡した直後は、TypeScriptは「loading / success / errorのどれか」としか分かりません。そのためstate.dataへのアクセスはエラーになります。
しかし、if (state.status === "success")の内側に入った瞬間、TypeScriptは「今のstateはsuccessの型だ」と判断し、必要なプロパティだけが存在する型に絞り込みます。条件分岐を通るだけで、特別なキャストをすることなく、その状態専用のプロパティへ安全にアクセスできるようになります。
3. 型安全を支える「判別子」と型の絞り込みの役割
① 判別子(discriminant)とは
判別子とは、ユニオン型を区別するための目印です。今回の例では、すべてのオブジェクトが共通して持っているstatusプロパティの文字列("loading" / "success" / "error")が判別子にあたります。
この目印があるおかげで、TypeScriptはどのオブジェクト型なのかを明確に判断できます。
判別子があると、「成功時だけ存在するプロパティ」をoptional(data?)に頼らず、型の構造そのもので表現できます。
② ifによるType Narrowing
Type Narrowing(型の絞り込み)とは、条件分岐の中でTypeScriptが変数の型をより具体的な型に狭めることです。判別可能なユニオン型ではstate.status === "success"のような条件が、絞り込みのきっかけになります。
function handleSafeState(state: SafeState) {
if (state.status === "success") {
state; // 型: { status: "success"; data: string }
} else if (state.status === "error") {
state; // 型: { status: "error"; errorMessage: string }
} else {
state; // 型: { status: "loading" }
}
}
各ブロック内でstateにホバーすると、上記のように型が変わっていることが確認できます。boolean + optionalでは、isLoading === falseと分かっても「successなのかerrorなのか」までは型で区別できません。判別子があれば、if1つでその状態専用のプロパティまで型が確定します。
まとめ
- 非同期UIの状態は、boolean + optionalより判別可能なユニオン型の方が「あり得ない状態」を型で排除しやすい。
-
statusのような判別子があると、if文の中だけ必要なプロパティに安全にアクセスできる。