【入門編】リテラル型ユニオンを用いた「状態遷移」の型定義 – TypeScript コア・型システムの基礎解析バイブル

こんにちは!フロントエンドからNode.jsまで、日夜コードと向き合っているシニアエンジニアです。

他の言語からTypeScriptの世界へ飛び込んだとき、「型があるって素晴らしい!」と感じる一方で、「どうやって複雑な状態の変化を型で表現すればいいんだろう?」と手が止まった経験はありませんか?

例えば、Webアプリでよくある「データの取得中(Loading)」「成功(Success)」「エラー(Error)」といった非同期のステート。これを適当なフラグの組み合わせ(`isLoading: boolean`, `hasError: boolean`など)で管理していると、「ローディング中なのにエラーが起きている」という、現実世界ではあり得ないバグ(不整合な状態)を生んでしまいがちです。

今回は、TypeScriptの強力な武器である「リテラル型ユニオン」を使って、「遷移不可能な状態をコンパイル時に完全に排除する」ステートマシンの設計パターンを一緒にマスターしていきましょう!

ここをクリアすれば、あなたのTypeScriptの型システムに対する見方はガラリと変わりますよ。それでは、早速いってみましょう!

—

1. なぜ「フラグ管理」は危険なのか?

まずは、よくある「やってしまいがちな設計」を見てみましょう。

// ❌ ありがちなフラグによる状態管理
type NetworkState = {
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
data: string | null;
error: Error | null;
};

この型定義だと、TypeScriptは以下のような「バグった状態」をすべて許容してしまいます。

// コンパイルエラーにならない(でも、現実にありえない状態!)
const invalidState: NetworkState = {
isLoading: true,
isSuccess: true, // ローディング中なのに成功してる!?
isError: true, // え、同時にエラーも?
data: “こんにちは”,
error: new Error(“失敗”),
};

これでは、コンパイラが私たちの味方をしてくれているとは言えませんよね。

—

2. リテラル型ユニオンで「状態」に名前を与える

ここで登場するのが、特定の文字列や数値だけを許容するリテラル型と、それらをパイプ(`|`)で繋ぐユニオン型です。

ステートマシン(状態機械)の考え方を取り入れ、「今、システムはどの状態にあるのか」を排他的(一度に1つの状態しか取れない)に定義します。

// 各状態を独立したオブジェクト(判別可能なユニオン型)として定義する
type IdleState = {
status: “IDLE”; // この状態であることを示すタグ
};

type LoadingState = {
status: “LOADING”;
};

type SuccessState = {
status: “SUCCESS”;
data: string; // 成功したときだけに持たせたいデータ
};

type ErrorState = {
status: “ERROR”;
error: Error; // エラーのときだけに持たせたい情報
};

// これらをまとめた「ネットワーク全体の状態」
type NetworkState = IdleState | LoadingState | SuccessState | ErrorState;

この定義の美しいところは、「成功データ (`data`) は `SUCCESS` の時にしか存在しない」「エラー (`error`) は `ERROR` の時にしか存在しない」というドメインの制約が、そのまま型に落とし込まれている点です。

—

3. 状態遷移関数を書いてみよう

では、このステートを安全に遷移させる関数を作ってみましょう。
ここでTypeScriptの網羅性チェック(Exhaustiveness Checking)という強力な恩恵を受けることができます。

// 現在の状態と、発生したイベントを受け取って、次の状態を返すイメージ
function handleAppEvent(currentState: NetworkState, event: “FETCH” | “RESOLVE” | “REJECT”): NetworkState {
switch (currentState.status) {
case “IDLE”:
if (event === “FETCH”) {
return { status: “LOADING” };
}
break;

case “LOADING”:
if (event === “RESOLVE”) {
return { status: “SUCCESS”, data: “取得したデータです” };
}
if (event === “REJECT”) {
return { status: “ERROR”, error: new Error(“通信失敗”) };
}
break;

case “SUCCESS”:
case “ERROR”:
// 成功やエラーの後に、もう一度リセットするなどの遷移もここで制御可能
if (event === “FETCH”) {
return { status: “LOADING” };
}
break;
}

// 遷移ルールに違反した場合は現在の状態をそのまま返す(あるいは例外を投げる)
return currentState;
}

🧠 脳内トレース:なぜこれが安全なのか?

もしあなたがうっかり、「`SUCCESS` の状態から直接エラー状態に遷移するコード」を書こうとしたり、存在しないプロパティ(例: `SUCCESS` 状態なのに `error` を参照するなど)にアクセスしようとすると、TypeScriptのコンパイラが即座に赤く波線を描いて教えてくれます。

コンパイル時にエラーを検知できるため、「本番環境でユーザーが変な操作をしたせいでアプリがクラッシュした」という悪夢を未然に防ぐことができるのです。

—

4. 使う側(UI層など)でのコードも劇的にスッキリ!

状態が綺麗に整理されていると、使う側(表示する側)のコードも非常にシンプルで安全になります。TypeScriptの「型の絞り込み(Narrowing)」が完璧に効くからです。

function renderUI(state: NetworkState) {
// `status` プロパティを見るだけで、TypeScriptが型を自動的に絞り込んでくれる
switch (state.status) {
case “IDLE”:
return “

ボタンを押してデータを取得してください

“;

case “LOADING”:
return “

読み込み中…

“;

case “SUCCESS”:
// ここでは state が SuccessState に絞り込まれているため、
// `state.data` が存在することが「保証」されています!
return `

データ: ${state.data}

`;

case “ERROR”:
// ここでは `state.error` に安全にアクセスできる
return `

エラー: ${state.error.message}

`;
}
}

「あれ、このプロパティって `undefined` かもしれないから、オプショナルチェーニング(`?.`)をつけなきゃだっけ?」と悩む必要はもうありません。型が保証されている世界は、本当にストレスフリーですよ。

—

まとめ:TypeScriptの型システムを使いこなそう

今回は、リテラル型ユニオンを用いたステートマシンの設計について解説しました。

  • フラグ管理をやめる: `isXXX` のようなフラグの組み合わせではなく、状態そのもの(`status`)をユニオン型で表現する。
  • 不可能な状態を排除する: その状態に必要なデータだけを同梱することで、バグの入り込む隙をコンパイル時に消し去る。
  • 絞り込みを活用する: `switch` 文や `if` 文で型が自動的に絞り込まれ、安全でメンテナブルなコードになる。

ここをクリアできれば、単なる「型付きJavaScript」から、「TypeScriptの型システムを意のままに操るエンジニア」への大きな一歩を踏み出したことになります。

ぜひ、皆さんの日々の開発や、次の個人開発のステート管理(ReduxやXState、あるいは自作のReducerなど)で試してみてくださいね。あなたのコードが、より堅牢で美しいものになることを応援しています!

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