こんにちは!フロントエンドから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 `
`;
case “ERROR”:
// ここでは `state.error` に安全にアクセスできる
return `
エラー: ${state.error.message}
`;
}
}
「あれ、このプロパティって `undefined` かもしれないから、オプショナルチェーニング(`?.`)をつけなきゃだっけ?」と悩む必要はもうありません。型が保証されている世界は、本当にストレスフリーですよ。
—
まとめ:TypeScriptの型システムを使いこなそう
今回は、リテラル型ユニオンを用いたステートマシンの設計について解説しました。
- フラグ管理をやめる: `isXXX` のようなフラグの組み合わせではなく、状態そのもの(`status`)をユニオン型で表現する。
- 不可能な状態を排除する: その状態に必要なデータだけを同梱することで、バグの入り込む隙をコンパイル時に消し去る。
- 絞り込みを活用する: `switch` 文や `if` 文で型が自動的に絞り込まれ、安全でメンテナブルなコードになる。
ここをクリアできれば、単なる「型付きJavaScript」から、「TypeScriptの型システムを意のままに操るエンジニア」への大きな一歩を踏み出したことになります。
ぜひ、皆さんの日々の開発や、次の個人開発のステート管理(ReduxやXState、あるいは自作のReducerなど)で試してみてくださいね。あなたのコードが、より堅牢で美しいものになることを応援しています!