TypeScript の Discriminated Union を使った State と API Response モデリング
1. 概要
多くのUI バグは本来決して発生しないはずのstate から生じています。例えば、isLoading が true なのに error にも値がある、といった具合です。または data が undefined なのに、リクエストは成功を返している、ということもあります。
この記事は、TypeScript の discriminated union を使ってデータ読み込みstate と API レスポンスをモデル化する方法を説明します。読み終わると、以下の方法が分かります:
- 判別フィールドを持つunionを定義する方法、
- コンパイラが自動的に型を絞り込む (narrowing) 仕組み、
- exhaustiveness check で全てのブランチの処理を強制する方法、
- Zod を使ってレスポンスを実行時に検証する方法、
- state の遷移をテストする方法。
2. 「オプショナルフィールドの袋」の問題
馴染み深いやり方は、すべてをオプショナルフィールドを持つインターフェースにまとめることです:
interface OrderState {
isLoading: boolean;
error?: string;
order?: { id: string; total: number };
}
const state: OrderState = {
isLoading: true,
error: "Timeout",
order: { id: "A1", total: 120 },
};
上のコードはコンパイルできます。しかし、ローディング中でもあり、エラーでもあり、データもあるという、無意味なstate を記述しています。
このような3つのフィールドで、2 × 2 × 2 = 8 の組み合わせが可能になります。実際には約 4 つの組み合わせだけが有効です。state を読み込むときにも、あちこちで state.order?.total や state.order! を書く必要があります。エラーが起きても、コンパイラは助けにはなりません。
3. Discriminated union とは
Discriminated union は複数のオブジェクト型のunionです。各型は共通のフィールドを持ちますが、異なる literal type を持ちます。このフィールドを discriminant または「tag」と呼びます:
type Order = { id: string; total: number };
type OrderState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; order: Order }
| { status: "error"; message: string };
ここで status は discriminant です。各ブランチは自身に意味のあるフィールドだけを宣言します。success ブランチは order を持ち、error ブランチは message を持ちます。「ローディング中なのにエラーである」というstate はもう表現できません:
const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.
3.1. Discriminant による型の絞り込み
Discriminant の値をチェックすると、TypeScript が対応するブランチ内で型を自動的に絞り込みます:
function describe(state: OrderState): string {
if (state.status === "success") {
return `注文 ${state.order.id}: ${state.order.total}円`;
}
if (state.status === "error") {
return `エラー: ${state.message}`;
}
return state.status === "loading" ? "ローディング中..." : "未開始";
}
console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
注文 A1: 120円
エラー: Timeout
if (state.status === "success") ブランチ内では、state の型は { status: "success"; order: Order } です。したがって、?. や ! なしで直接 state.order.id にアクセスできます。このブランチ内で state.message と入力すると、コンパイラはエラーを報告します。
4. Exhaustiveness check: 状態を見落とさない
Union が本当に役に立つのは、新しいブランチを追加したときにコンパイラがそのブランチを処理していない場所を指摘することです。一般的な方法は switch を never 型と組み合わせることです:
function assertNever(value: never): never {
throw new Error(`処理されていない状態: ${JSON.stringify(value)}`);
}
function render(state: OrderState): string {
switch (state.status) {
case "idle":
return "<button>注文を読み込む</button>";
case "loading":
return "<spinner />";
case "success":
return `<p>合計: ${state.order.total}円</p>`;
case "error":
return `<p class="error">${state.message}</p>`;
default:
return assertNever(state);
}
}
4つの case をすべて通過した後、default ブランチの state の型は never です。したがって、assertNever(state) の呼び出しは有効です。
後になって OrderState に { status: "cancelled" } ブランチを追加したけど、render を更新し忘れたとしましょう:
error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.
エラーはビルド時に表示され、ユーザーが白い画面を見る前に気づきます。信頼できない API など、実行時に予期しないデータが入ってくる場合でも、assertNever は明確なエラーをスローします。黙って undefined を返すことはありません。
注:
"strict": trueをオンにして戻り値の型を明示的に宣言 (: string) すれば、caseを忘れた場合も「Function lacks ending return statement」エラーが出ます。それでもassertNeverは使う価値があります。より明確なエラーメッセージを提供し、voidを返す関数でも機能します。
5. API レスポンスのモデル化
API は通常、成功または失敗の2つの形式で応答します。共通の Result 型を定義できます:
type Result<T, E = ApiError> =
| { ok: true; data: T }
| { ok: false; error: E };
type ApiError =
| { kind: "network"; cause: string }
| { kind: "http"; status: number }
| { kind: "invalid"; issues: string[] };
Discriminant は文字列である必要はありません。ok: true と ok: false はbooleanの2つのliteral typeなので、narrowing は依然として機能します。ApiError もネストされたdiscriminated unionで、kind で区別されます。
5.1. Zod を使った実行時検証
TypeScript の型はコンパイル後に消えます。(await res.json()) as Order と書けば、コンパイラに「約束」するだけで、実際には何も検証していません。
サーバーからのデータが正しい形式であることを確認するには、Zod を使って検証してください:
npm install zod
サーバーが discriminant として type を持つペイロードを返すとしましょう:
import { z } from "zod";
const OrderSchema = z.object({ id: z.string(), total: z.number() });
const OrderResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("found"), order: OrderSchema }),
z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);
type OrderResponse = z.infer<typeof OrderResponseSchema>;
z.discriminatedUnion("type", [...]) は type フィールドに基づいてスキーマを作成します。Zod は最初に type の値を読み込んでから正しいブランチに従って検証するため、z.union よりエラーメッセージが正確です。z.infer は対応する TypeScript 型を生成するため、型を2回書く必要がありません。
次は API 呼び出し関数で、すべてのエラーを Result でラップします:
async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
let res: Response;
try {
res = await fetch(`/api/orders/${id}`);
} catch (e) {
return { ok: false, error: { kind: "network", cause: String(e) } };
}
if (!res.ok) {
return { ok: false, error: { kind: "http", status: res.status } };
}
const parsed = OrderResponseSchema.safeParse(await res.json());
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
return { ok: false, error: { kind: "invalid", issues } };
}
return { ok: true, data: parsed.data };
}
この関数は外部に例外をスローしません。すべての状況が戻り値の型に反映されます。safeParse 自体も discriminant が success のdiscriminated union を返します。これがあなたが学んでいるパターンそのものです。
関数を呼び出す側は、次のように結果を処理します:
async function showOrder(id: string): Promise<string> {
const result = await fetchOrder(id);
if (!result.ok) {
switch (result.error.kind) {
case "network": return "接続が失われました。再度お試しください。";
case "http": return `サーバーが ${result.error.status} を返しました。`;
case "invalid": return "データが無効です。";
default: return assertNever(result.error);
}
}
return result.data.type === "found"
? `合計: ${result.data.order.total}円`
: `注文 ${result.data.orderId} が見つかりません`;
}
if (!result.ok) { ... } の行の後、TypeScript は result が確実に ok: true ブランチであることを知っています。したがって、result.data に直接アクセスできます。
6. Reducer を使ったstate の遷移
Discriminated union は reducer に非常に適しています。例えば React の useReducer や自作のstore など。state と action の両方がunionになります:
type OrderAction =
| { type: "FETCH" }
| { type: "RESOLVE"; order: Order }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "FETCH":
return state.status === "loading" ? state : { status: "loading" };
case "RESOLVE":
return state.status === "loading"
? { status: "success", order: action.order }
: state;
case "REJECT":
return state.status === "loading"
? { status: "error", message: action.message }
: state;
case "RESET":
return { status: "idle" };
default:
return assertNever(action);
}
}
このreducer は状態遷移ルールをエンコードしています。RESOLVE は loading 時のみ有効です。これにより、ユーザーが RESET した後に遅れてきたレスポンスは無視されます。追加のフラグは必要ありません。
7. コア動作のテストを書く
この記事では Vitest を使います。その expect 構文は AssertJ や Jest に似ています:
npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";
describe("orderReducer", () => {
const order = { id: "A1", total: 120 };
it("RESOLVE で loading から success に遷移する", () => {
const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "success", order });
});
it("idle 時は遅れてきた RESOLVE を無視する", () => {
const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "idle" });
});
it("render はエラーメッセージを表示する", () => {
expect(render({ status: "error", message: "Timeout" }))
.toBe('<p class="error">Timeout</p>');
});
});
npx vitest run
✓ order.test.ts (3 tests)
✓ orderReducer > RESOLVE で loading から success に遷移する
✓ orderReducer > idle 時は遅れてきた RESOLVE を無視する
✓ orderReducer > render はエラーメッセージを表示する
Test Files 1 passed (1)
Tests 3 passed (3)
2番目のテストが最も重要です。レースコンディション対策の動作をロックします。これは「オプショナルフィールドの袋」モデルで非常に起こりやすいエラーの種類です。
8. よくあるエラーと使うべきでない時
Discriminant が string に「拡張」される。 中間変数でオブジェクトを作成する場合、TypeScript はliteral型の代わりに string 型を推論する可能性があります:
const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.
3つの修正方法があります: 型を最初から宣言する (const s: OrderState = ...)、as const を使う、または satisfies OrderState を使う。
早期の分割代入が連携を失う。 let { status, ...rest } = state と書くと、TypeScript は status をチェックするときに rest がどのブランチに属しているかわかりません。分割代入による narrowing は、変数が const で宣言され、再割り当てされない場合にのみ機能します。
Discriminant の代わりに in 演算子を使う。 "order" in state のチェックも narrowing できます。ただし、2つのブランチが同じフィールド名を持つ場合、壊れやすくなります。常に discriminant でチェックしてください。
Discriminant がオプショナルまたは変更可能である。 status?: ... と宣言しないでください。既存のオブジェクトに state.status = "error" を割り当てないでください。代わりに、各state に対して新しいオブジェクトを作成してください。フィールドを readonly とマークして、コンパイラにこのルールを守らせることができます。
検証の代わりに型キャストを使う。 ネットワークからのデータに as OrderResponse と書くと、上記のすべての利点が失われます。システムの境界では、常に実行時にデータを検証してください。
注: フィールドが本当に独立しているときは、discriminated union を使うべきではありません。例えば、フォームに
email?、phone?、note?があり、あらゆる組み合わせで埋めることができます。この場合、オプショナルフィールドが正しいモデルです。それらを union に無理やり変えると、ブランチの数が爆発的に増え、安全性の向上が得られません。
9. まとめ
Discriminated union は無効な状態をコンパイルエラーに変えるのに役立ちます。以下の方法を学びました:
- リテラル discriminant を持つunionを定義する方法、
- TypeScript が discriminant に基づいて自動的に型を絞り込む方法、
assertNeverを使ってすべてのブランチの処理を強制する方法、ResultでAPI レスポンスをモデル化し、z.discriminatedUnionでデータを検証する方法、- reducer に同じパターンを適用し、テストで検証する方法。
簡潔なルールは: フィールドが一緒に意味を持つだけの場合、unionの同じブランチに置きます。次に isLoading を error? と data? の隣に書こうとしたら、代わりに status フィールドで置き換えてみてください。