こんにちは!フロントエンドからバックエンドまで、TypeScriptの型システムを知り尽くしたチーフアーキテクトの先輩です。
今回は、TypeScriptの基本をもう一段階レベルアップさせ、実務で即戦力となる「リテラル型ユニオンを用いた状態遷移の型安全なハンドリング」についてお話ししますね。
「ボタンを押したらローディング中になり、成功したらデータ表示、失敗したらエラー表示……」
Webアプリケーションを作っていると、こうした「状態(State)」の管理に頭を悩ませることはありませんか?
「通信中なのに削除ボタンが押せてしまった!」
「エラーなのにローディングのぐるぐる回るアイコンが出っぱなしになっている!」
こうしたバグは、行き当たりばったりのフラグ管理(`isLoading: boolean` や `isError: boolean` など)が原因で起こります。でも大丈夫。TypeScriptのリテラル型と判別可能な共用体(Discriminated Unions)を使いこなせば、「不正な状態の組み合わせそのものをコンパイルエラーで弾く」という堅牢な世界を手に入れることができますよ。
ここをクリアすれば、あなたの書くコードの信頼性は劇的に跳ね上がります。一緒に本質をマスターしていきましょう!
—
1. そもそも「リテラル型ユニオン」ってなに?
TypeScriptにおけるリテラル型とは、広範な `string` や `number` 型ではなく、「その値そのもの」を型として定義する手法です。
例えば、ただの `string` 型は「どんな文字列でも入る」のに対し、リテラル型は「`”idle”` という文字しか入らない」と強制します。
// 単なる文字列型
let statusStr: string = “idle”;
statusStr = “fetching”; // なんでも入る
// 文字列リテラル型
let statusLiteral: “idle” | “fetching” = “idle”;
statusLiteral = “fetching”; // OK
// statusLiteral = “success”; // ❌ コンパイルエラー!”success” は割り当てられません
この「特定の文字列や数値をパイプ(`|`)で繋いだもの」をリテラル型ユニオンと呼びます。これを使うことで、アプリが取りうる状態をカッチリと限定できるんです。
—
2. 不正な状態を防ぐ!「判別可能な共用体」の設計
では、具体的な有限状態マシン(FSM)を考えてみましょう。
データ取得(Fetch)のプロセスを、次の4つの状態に分解します。
1. 初期状態 (Idle)
2. 通信中 (Loading)
3. 成功 (Success) – データを持っている
4. 失敗 (Failure) – エラー情報を持っている
これをバラバラのフラグで管理すると、`isLoading` が `true` なのに `data` が存在するという、矛盾した状態(あり得ない状態)が型の上で許容されてしまいます。
そこで、次のように「状態ごとにオブジェクトの型を完全に分離」します。これが判別可能な共用体です。
// 各状態の型定義
type IdleState = {
status: “IDLE”;
};
type LoadingState = {
status: “LOADING”;
};
type SuccessState = {
status: “SUCCESS”;
data: string; // 成功時は必ずデータが存在する
};
type FailureState = {
status: “FAILURE”;
error: Error; // 失敗時は必ずエラーが存在する
};
// これらをまとめた「通信状態」の共用体型
type FetchState = IdleState | LoadingState | SuccessState | FailureState;
ここでポイントなのが、すべての型に共通して持たせた `status` プロパティです。これを「ディスクリミネーター(判別子)」と呼びます。TypeScriptはこの `status` を見るだけで、今どの型であるかを完璧に識別できるようになります。
—
3. 安全な状態ハンドリングの実装
状態を定義できたら、次はそれを安全に処理するハンドラー関数を作ってみましょう。
ここでTypeScriptの強力な型の絞り込み(Type Narrowing)が真価を発揮します。
function handleFetchState(state: FetchState) {
// statusプロパティの値によって、TypeScriptが自動的に型を絞り込んでくれる!
switch (state.status) {
case “IDLE”:
console.log(“まだ開始していません。”);
break;
case “LOADING”:
console.log(“データを取得中…”);
break;
case “SUCCESS”:
// ここでは state が SuccessState に絞り込まれているため、
// データの存在が型安全に保証される!
console.log(“取得成功:”, state.data.toUpperCase());
break;
case “FAILURE”:
// ここでは state が FailureState に絞り込まれている
console.error(“取得失敗:”, state.error.message);
break;
}
}
なぜこれが強力なのか?
もし、将来的に要件が変わり、新しい状態 `”RETRYING”` を追加したとします。
そのとき、もし `handleFetchState` の `switch` 文に `”RETRYING”` の処理を書き忘れたらどうなるでしょうか?
ここで、TypeScriptの網羅性チェック(Exhaustive Check)のテクニックが活きてきます。
// 網羅性チェック用のヘルパー関数
function assertNever(x: never): never {
notImplementedError(x); // 実際のエラースローなど
throw new Error(`想定外の値を受け取りました: ${JSON.stringify(x)}`);
}
function handleFetchStateStrict(state: FetchState) {
switch (state.status) {
case “IDLE”:
// …省略
break;
case “LOADING”:
// …省略
break;
case “SUCCESS”:
// …省略
break;
case “FAILURE”:
// …省略
break;
default:
// すべてのケースが網羅されていれば、ここには絶対に到達しない(型は never になる)
// もし新しい状態を追加し忘れると、ここにその状態が流れ込んできてコンパイルエラーになる!
return assertNever(state);
}
}
この `assertNever` を仕込んでおけば、「新しい状態を追加したのに、ハンドリングし忘れた!」というヒューマンエラーを、実行時ではなくコンパイル時に100%検知できるようになります。これがプロの型設計です。
—
4. 陥りやすい罠:間違った状態遷移の書き方
初学者の頃によくやってしまうアンチパターンについても触れておきましょう。
❌ やってはいけない例:すべてのプロパティを1つの型に詰め込む
// ぜんぶオプショナル(?)にして1つの型にしてしまう
type BadState = {
status: “IDLE” | “LOADING” | “SUCCESS” | “FAILURE”;
data?: string;
error?: Error;
};
これだと、`status` が `”LOADING”` なのに `data` が入ってしまっているようなバグコードでも、TypeScriptはエラーを出してくれません。「型はあるのに、守ってくれない」という悲しい状況になります。
必ず「その状態のときに存在するべきデータだけを、その状態の型に閉じ込める」ようにしてください。
—
まとめ:ここをクリアすれば、TypeScriptの基本はバッチリ!
今回は、リテラル型ユニオンと判別可能な共用体を使って、堅牢な有限状態マシンを構築する方法を解説しました。
- リテラル型ユニオンで、取りうる状態を厳密に制限する。
- 判別可能な共用体(共通の `status` プロパティ)で、状態ごとのデータ構造を分離する。
- `switch` 文と型の絞り込みで、安全にハンドリングする。
- `assertNever` で、将来の仕様変更に伴うハンドリング漏れを防ぐ。
このパターンは、フォームの入力状態、非同期通信、画面の画面遷移など、フロントエンドのあらゆる場所で応用できます。ここをマスターすれば、もう「なんとなく動くコード」から卒業し、「型に守られて安心してリファクタリングできるコード」を書けるようになりますよ。
ぜひ、日々の開発のステート管理に取り入れてみてくださいね。それでは、次のステップへ進みましょう!