TypeScript 中的 Discriminated Union:用于状态和 API 响应
1. 概览
许多界面错误来自于原本不应该发生的状态。比如 isLoading 是 true,但 error 也有值。或者 data 是 undefined,尽管请求已报告成功。
本文将指导你使用 TypeScript 的 discriminated union 来建模数据加载状态和 API 响应。读完后,你将了解如何:
- 定义带有判别字段的 union,
- 让编译器自动进行类型收窄(narrowing),
- 通过穷尽性检查(exhaustiveness check)强制处理所有分支,
- 使用 Zod 在运行时验证响应,
- 为状态转换编写测试。
2. “可选字段大杂烩”的问题
常见的做法是将所有内容放在一个接口中,其字段都是可选的:
interface OrderState {
isLoading: boolean;
error?: string;
order?: { id: string; total: number };
}
const state: OrderState = {
isLoading: true,
error: "Timeout",
order: { id: "A1", total: 120 },
};
上述代码编译没问题。但它描述了一个无意义的状态:既在加载,又出错,又有数据。
有三个这样的字段,你有 2 × 2 × 2 = 8 种组合。实际上只有大约 4 种是合法的。读取状态时,你还要在各处写 state.order?.total 或 state.order!。编译器无法帮助。
3. Discriminated Union 是什么
Discriminated union 是多个对象类型的 union。每种类型都有一个共同字段,该字段的值是字面量类型且彼此不同。这个字段叫做 discriminant,或“标签”:
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。现在就无法表示“既在加载又出错”这个状态了:
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. 穷尽性检查:不漏掉任何状态
只有当添加新分支时,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);
}
}
经过所有四个 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 通常返回两种形式:成功或失败。你可以定义一个通用的 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 的两个字面量类型,所以类型收窄仍然有效。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 字段创建 schema。Zod 先读取 type 值,然后根据正确的分支进行验证,所以错误消息比 z.union 更准确。z.infer 生成对应的 TypeScript 类型,所以你不需要写两次类型。
接下来是 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。这就是你正在学习的 pattern。
调用该函数的一方处理结果如下:
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 进行状态转换
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)
第二个测试最重要。它锁定了防止竞态条件的行为,这是“可选字段大杂烩”模型很容易遇到的那种错误。
8. 常见错误和何时不应该使用
Discriminant 被“扩宽”为 string。 当你在中间变量中创建对象时,TypeScript 可能会推断 string 类型而不是字面量:
const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.
你有三种方法修复:从一开始就声明类型(const s: OrderState = ...)、使用 as const 或使用 satisfies OrderState。
提前解构会失去关联。 如果你写 let { status, ...rest } = state,当你检查 status 时,TypeScript 不再知道 rest 属于哪个分支。通过解构的类型收窄只在变量用 const 声明且你不重新赋值时有效。
使用 in 操作符代替 discriminant。 检查 "order" in state 仍然可以进行类型收窄。但这种方法容易在两个分支有相同字段名时出问题。始终通过 discriminant 进行检查。
Discriminant 可选或可被修改。 不要声明 status?: ...。也不要在现有对象上赋值 state.status = "error"。相反,为每种状态创建新对象。你可以将字段标记为 readonly 来让编译器强制执行这个规则。
强制转换而不是验证。 对网络数据写 as OrderResponse 会消除上面的所有好处。在系统边界,总是在运行时验证数据。
注意: 当字段真的彼此独立时,不应该使用 discriminated union。例如,一个表单有
email?、phone?、note?可以按任何组合填充。在这种情况下,可选字段是正确的模型。强制它们成为 union 只会增加分支数量而不会增加安全性。
9. 总结
Discriminated union 帮助你将非法状态变成编译错误。你已经看到了如何:
- 定义带有字面量 discriminant 的 union,
- 让 TypeScript 按 discriminant 自动收窄类型,
- 使用
assertNever强制处理所有分支, - 使用
Result建模 API 响应并用z.discriminatedUnion验证数据, - 将相同的 pattern 应用于 reducer 并用测试进行验证。
简短的规则是:如果字段只有在一起时才有意义,就把它们放在 union 的同一个分支中。下次当你想在 isLoading 旁边写 error? 和 data? 时,不妨试试用一个 status 字段来替代。