《TypeScript编程入门》7.2 判别联合(Discriminated Unions)

本节讲解判别联合,也就是给联合的每个成员加一个共同的判别字段,让编译器能顺着这个字段自动区分当前处于哪种形态。我们会从「用布尔标志描述状态」的经典痛点出发,一步步重构成判别联合,再用 never 做穷尽性检查,保证新增分支时不会漏处理。你还将看到判别字段的选取原则、与可选字段建模的对比,以及真实项目里常见的踩坑点。读完后你能为请求状态、事件、表单结果这类场景写出可穷尽、可维护的建模。

本节目标:理解判别联合的构成要件(联合成员 + 公共判别字段 + 字面量类型),学会把「一堆可选字段」重构为「若干互斥形态」,掌握用 never 做穷尽性检查的写法,并能判断什么场景该用判别联合、什么场景不该用。

7.2 判别联合(Discriminated Unions)

上一节我们把「一组固定取值」表达成了字面量联合。但业务里更复杂的情形是:一个对象整体有若干种互斥的形态,每种形态携带的字段还不一样。加载中的请求没有数据,成功的请求有数据,失败的请求有错误信息——它们都是「请求状态」,但结构不同。

判别联合(discriminated union,也叫 tagged union / algebraic data type)就是 TypeScript 处理这类建模的标准手法。它由三部分组成:

  1. 若干对象类型组成的联合;
  2. 每个成员都有一个同名的公共字段,称为判别字段(discriminant);
  3. 这个字段的类型是字面量类型,且各成员取值互不相同。

三者齐备后,编译器就能顺着判别字段自动收窄到具体成员。下面从痛点讲起。

先看一个糟糕的建模

设想我们要表示一次异步请求的状态。很多项目会这样写:

interface RequestState<T> {
  loading: boolean;
  data?: T;
  error?: string;
}

这个接口「看起来能表达所有情况」,但它是不设防的。它允许下面这些在逻辑上互相矛盾的对象全部通过编译:

const a: RequestState<User> = { loading: true, data: someUser };   // 加载中却有数据?
const b: RequestState<User> = { loading: false };                  // 加载完了但既无数据也无错误?
const c: RequestState<User> = { loading: false, data: u, error: "boom" }; // 同时成功与失败?

问题出在哪?loading、data、error 三个字段是彼此独立的,类型系统并不知道它们之间该有约束。于是使用者每次拿到这个对象,都得靠自觉去判断:

function render(state: RequestState<User>) {
  if (state.loading) {
    return "加载中...";
  }
  if (state.error) {
    return `出错了:${state.error}`;
  }
  if (state.data) {
    return `你好,${state.data.name}`;
  }
  return "?"; // 这行永远不该走到,但类型系统逼你写
}

注意最后那个 return "?"——它是类型系统逼你写出来的死代码。因为你无法向编译器证明「前面三个分支已经覆盖了全部情况」。而一旦有人新增了 cancelled 状态,这里不会有任何编译错误提醒你去补分支。

用判别联合重构

把上面这个接口拆成三个互斥的形态,各自带一个 status 判别字段:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

现在再看同一段渲染逻辑:

function render(state: RequestState<User>): string {
  switch (state.status) {
    case "idle":
      return "尚未开始";
    case "loading":
      return "加载中...";
    case "success":
      return `你好,${state.data.name}`; // 这里 state 已收窄,data 必定存在
    case "error":
      return `出错了:${state.error}`;   // 这里 error 必定存在
  }
}

变化是根本性的:

  • case "success" 分支里,state.data 的类型是 User,不是 User | undefined,不需要任何可选链或断言;
  • 在 case "loading" 分支里尝试访问 state.data 会直接编译报错,因为该形态根本没有这个字段;
  • 不可能再构造出「加载中却带数据」这种矛盾对象,编译器在赋值处就会拦下;
  • 函数不再需要那句兜底的 return "?"——不过前提是打开了穷尽性检查,见下文。

判别字段的选取原则

判别字段(也叫 tag)的选择有两条硬性要求,和一条软性建议。

硬性要求一:所有成员都必须有该字段,且名字相同。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

如果某个成员漏了 kind,判别联合就退化成普通联合,收窄不会生效。

硬性要求二:各成员的取值必须是字面量类型且互不重复。

// 错误示范:kind 写成 string,收窄失效
type Bad =
  | { kind: string; radius: number }
  | { kind: string; side: number };

两个成员都是 string,编译器无法从值区分它们。必须是 "circle" | "square" 这样的字面量。

软性建议:字段名用 kind / type / tag / status 等语义明确的词。 本书统一使用 kind 表示「这是什么形态」,用 status 表示「处于什么状态」,用 type 表示「这是哪类事件/消息」。团队内保持一致比选哪个词更重要。

穷尽性检查:让新增分支变成编译错误

判别联合最有价值的收益是穷尽性检查(exhaustiveness check):当联合新增一个成员时,编译器能指出所有没处理它的地方。实现方式是借 never 类型做一次兜底。

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default: {
      const exhaustive: never = shape;
      throw new Error(`未处理形状: ${JSON.stringify(exhaustive)}`);
    }
  }
}

never 是「不可能存在的值」的类型(在 3.3 any·unknown·never·void 与类型断言 中介绍过)。如果所有成员都被 case 覆盖,走到 default 时 shape 的类型已经被收窄成 never,const exhaustive: never = shape 合法。反之,一旦有人新增了 kind: "triangle" 却没加分支,default 里的 shape 就是 { kind: "triangle"; ... },赋值给 never 会立刻报错:

Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.

这就是「漏处理分支」从一个运行期 bug 变成了编译期错误。

也可以把这段兜底逻辑抽成一个工具函数,供全项目复用:

function assertNever(value: never, message = "unexpected value"): never {
  throw new Error(`${message}: ${JSON.stringify(value)}`);
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      return assertNever(shape, "未处理的形状");
  }
}

在 if/else 链里同样适用,只是需要显式收尾:

function describe(value: string | number | boolean): string {
  if (typeof value === "string") return `字符串: ${value}`;
  if (typeof value === "number") return `数字: ${value}`;
  if (typeof value === "boolean") return `布尔: ${value}`;
  return assertNever(value); // 三个分支都覆盖后,value 为 never
}

判别联合与「可选字段」的取舍

判别联合不是唯一解。有些场景用可选字段反而更简洁,判断依据是「字段之间是否存在互斥约束」。

场景特征推荐写法原因
多个字段互斥,同一时刻只有一组有效判别联合类型层面禁止矛盾组合
字段彼此独立,可任意组合普通 interface + 可选字段判别联合会把成员数组合爆炸
需要穷尽性检查(新增分支要报错)判别联合只有联合能配 never 兜底
只是「可能有也可能没有」field?: T 或 T | undefined判别联合属于过度设计
状态机式流转(idle→loading→success/error)判别联合每个状态的载荷不同

一个反例:如果对象有 5 个彼此独立的开关,用判别联合会写出 2⁵ = 32 个成员,完全不可维护。此时应该老老实实用 5 个布尔字段。

在真实项目中的典型用法

判别联合最常见的三个落点是状态、事件/动作、结果。

第一类是请求状态,前面已经演示。第二类是 Redux 风格的动作:

type Action =
  | { type: "user/login"; payload: { email: string; password: string } }
  | { type: "user/logout" }
  | { type: "cart/add"; payload: { sku: string; qty: number } }
  | { type: "cart/clear" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "user/login":
      return { ...state, user: login(action.payload) };
    case "user/logout":
      return { ...state, user: null };
    case "cart/add":
      return { ...state, cart: addItem(state.cart, action.payload) };
    case "cart/clear":
      return { ...state, cart: [] };
  }
}

这里 action.type 就是判别字段,case 分支里 action.payload 的类型各不相同,全靠 type 收窄。

第三类是「成功/失败」结果,配合错误处理使用:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function parseAge(input: string): Result<number> {
  const n = Number(input);
  if (!Number.isInteger(n) || n < 0) {
    return { ok: false, error: new Error(`非法年龄: ${input}`) };
  }
  return { ok: true, value: n };
}

const result = parseAge("42");
if (result.ok) {
  console.log(result.value); // number
} else {
  console.error(result.error.message); // Error
}

ok: true / ok: false 这对布尔字面量就是判别字段,用法与字符串判别字段完全一致。这套模式在 14.1 错误类型与 Result 模式 会专门展开,如果你想提前了解,可以看本站的 TypeScript 错误处理与 Result 模式 。

常见坑与错误信息

现象原因处理方式
Property 'data' does not exist on type '{ status: "loading" }'访问了当前形态没有的字段先在 case 中按判别字段收窄
收窄完全不生效,类型仍是整个联合判别字段不是字面量类型(写成了 string)改用 "a" | "b" 字面量联合
default 里的 never 赋值报错联合新增成员但漏了分支补上缺失的 case
联合成员数爆炸、难以维护把彼此独立的字段硬拆成互斥形态改回普通 interface + 可选字段
收窄只在函数体内有效,回调里失效闭包捕获的变量会被重新拓宽先把值赋给局部常量再用(见 7.3)
从 JSON 解析出的对象直接当判别联合用运行期数据没有类型保证用 Zod 等做边界校验(见 13.2)

最后一条尤其要提醒:判别联合描述的是编译期的形态,它不会自动校验外部数据。JSON.parse 的返回值是 any,你把它断言成判别联合,编译器会照单全收,运行期该崩还是崩。真正的防线是在边界处做运行期校验,这属于 13.2 Zod 模式验证与类型推导 的主题,本站的 运行时校验与类型安全 也有更完整的讨论。

与类型守卫的关系

判别联合之所以能收窄,本质上依赖的是控制流分析:编译器分析 switch (state.status) 与各个 case 的字面量比较,推断出每个分支里 state 的具体类型。这套机制不仅适用于 switch,也适用于 if、&&、三元表达式,并且不只认判别字段——typeof、instanceof、in、自定义守卫都能触发收窄。

换句话说,判别联合是「让编译器自己推断」,而类型守卫是「你告诉编译器」。当数据结构本身已经带上了可区分的字段,判别联合是零成本的首选;当区分依据需要运行期计算(比如判断一个对象是否实现了某个接口的全部方法),就得自己写守卫。下一节 7.3 类型守卫与控制流分析 会把这两条路线完整讲透。

小结

本节的核心是把「一堆可选字段」升级为「若干互斥形态」。判别联合的三要件是:对象类型的联合、同名的公共判别字段、互不重复的字面量取值。三者齐备后,switch 或 if 中对判别字段的比较会让编译器自动收窄,分支内访问专属字段不再需要可选链或断言。

更重要的是穷尽性检查:在 default 分支里把值赋给 never,就能让「新增成员却漏处理」从运行期 bug 变成编译期错误。这是判别联合相对可选字段最不可替代的价值。

不过判别联合解决的是「有天然区分字段」的场景。当区分依据不体现在数据结构上时,我们需要主动进行运行期检查,并把检查结果告诉编译器——这就是下一节要讲的类型守卫与控制流分析。

阅读导航:上一节:7.1 联合类型与字面量类型 · 下一节:7.3 类型守卫与控制流分析 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes