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 字段来替代。

typescriptdiscriminated uniontype safetystate managementapi