☰
TypeScript 判别联合(Discriminated Unions)实战指南:用判别属性实现精准类型收窄与穷尽性检查
2026/9/25 5:26:26 网站建设 项目流程
  • 文档
  • 教程

【免费下载链接】typescript-book

The Concise TypeScript Book: A Concise Guide to Effective Development in TypeScript. Free and Open Source.

项目地址:https://gitcode.com/gh_mirrors/typ/typescript-book
点击查看免费下载

本文是开源项目《The Concise TypeScript Book》(关联文档)中"Unions discriminées(判别联合)"一章的深度讲解。文章以该章节的 Square/Circle/Shape 示例为主线,结合仓库中 union-type.md、narrowing.md、control-flow-analysis.md、exhaustiveness-checking.md 等相邻章节,系统说明判别联合的定义、判别属性的作用原理、与类型收窄的配合方式,以及如何借助never类型做穷尽性检查。读完本文,你将掌握用判别联合建模"多种形态"数据、在switch/if中安全收窄类型、并在新增分支时让编译器替你兜底的类型安全开发方案。

什么是判别联合

在 TypeScript 中,**判别联合(Discriminated Union,又称 Tagged Union / 可辨识联合)是一种特殊的联合类型:它通过一个公共属性(discriminant,判别属性)**来缩小联合中可能类型的集合。当联合的每个成员类型都拥有同一个属性,且该属性在各自成员中被声明为不同的字面量类型时,TypeScript 就能依据这个属性在运行时精确地区分每个成员。

其语法基础来自两类 TypeScript 核心机制:

  • 联合类型(Union Type):表示一个值可以是若干类型之一,用|符号连接每个可能的类型(参见 union-type.md):
let x: string | number; x = 'hello'; // Valid x = 123; // Valid
  • 字面量类型(Literal Type):某个集合中的单元素类型,精确对应一个 JavaScript 原始值。字符串、数字、布尔值都可以作为字面量类型(参见 literal-types.md):
const a = 'a'; // String literal type const b = 1; // Numeric literal type const c = true; // Boolean literal type type O = 'a' | 'b' | 'c'; // 只允许这三个字符串值

判别联合正是这两者的结合:联合中每个成员都以不同的字面量值填充同一个判别属性,使该属性成为运行时判断的依据。

核心示例:用kind判别属性建模图形

原文档给出的经典示例是用一个kind属性区分正方形与圆形。kind在Square中被声明为字面量类型'square',在Circle中被声明为'circle',因此kind就是联合的判别属性(discriminant):

type Square = { kind: 'square'; // Discriminant size: number; }; type Circle = { kind: 'circle'; // Discriminant radius: number; }; type Shape = Square | Circle; const area = (shape: Shape) => { switch (shape.kind) { case 'square': return Math.pow(shape.size, 2); case 'circle': return Math.PI * Math.pow(shape.radius, 2); } }; const square: Square = { kind: 'square', size: 5 }; const circle: Circle = { kind: 'circle', radius: 2 }; console.log(area(square)); // 25 console.log(area(circle)); // 12.566370614359172

逐步拆解这个示例:

  1. 建模阶段:Square拥有kind: 'square'与size: number;Circle拥有kind: 'circle'与radius: number。两个成员没有公共的"形状"字段,唯一共有的就是判别属性kind。
  2. 组合阶段:type Shape = Square | Circle把两者合成为联合类型,一个Shape值要么是正方形、要么是圆形,不可能同时是两者。
  3. 消费阶段:area函数以shape: Shape为参数,在switch (shape.kind)中对判别属性做等值比较。TS 编译器在case 'square'分支内将shape收窄为Square,因此可以安全访问shape.size;在case 'circle'分支内收窄为Circle,可以安全访问shape.radius。
  4. 结果验证:area(square)输出25(即 5²),area(circle)输出12.566370614359172(即 π·2²),与运行时实际计算一致。

这里的核心要点是:判别属性必须使用字面量类型(而非string),因为字面量值才是编译期可识别的"标签"。若把kind声明为普通string,编译器将无法在分支中区分两个成员。

判别联合的底层机制:类型收窄(Narrowing)

判别联合之所以能安全访问各分支的专属属性,依赖的是 TypeScript 的类型收窄(narrowing)机制——在条件块内部,将变量的类型精化为其联合类型的子集。仓库 narrowing.md 列举了多种收窄途径,其中与判别联合直接相关的是等值收窄(Equality narrowing):

Equality narrowing in TypeScript works by checking whether a variable is equal to a specific value or not, to narrow its type accordingly. It is used in conjunction withswitchstatements and equality operators such as===,!==,==, and!=to narrow down types.

const checkStatus = (status: 'success' | 'error') => { switch (status) { case 'success': return true; case 'error': return null; } };

area示例中的switch (shape.kind)正是等值收窄在判别联合上的应用:判别属性的字面量值('square'/'circle')与case分支的字面量标签逐一匹配,编译器据此把联合收缩到单一成员。除switch外,if (shape.kind === 'square')同样可以触发收窄。

为了对比,narrowing.md 还介绍了另外四种收窄方式,它们适用于不同场景:

收窄方式判别依据适用场景
typeof类型守卫变量的内建 JS 类型string \| number这类基础类型联合
Truthiness 收窄变量是否为真值string \| null等可空值
等值收窄(Equality)变量是否等于某特定值switch+===/!==/==/!=,判别联合的核心手段
in运算符收窄属性是否存在于对象类型中成员间没有公共判别属性的对象联合
instanceof收窄对象的构造函数基于类的联合

in运算符收窄尤其值得注意:当联合成员没有公共判别属性时(例如Dog有breed、Cat有likesCream),可以用'breed' in pet来判断成员类型(见 narrowing.md):

type Dog = { name: string; breed: string; }; type Cat = { name: string; likesCream: boolean; }; const getAnimalType = (pet: Dog | Cat) => { if ('breed' in pet) { return 'dog'; } else { return 'cat'; } };

相比之下,判别联合让成员共享同一个判别属性,代码意图更明确,也更便于编译器做穷尽性检查。

判别属性与控制流分析(TypeScript 4.4+)

判别联合的收窄还依赖 TypeScript 的控制流分析(Control Flow Analysis)。仓库 control-flow-analysis.md 指出:从 TypeScript 4.4 起,控制流分析不仅作用于if语句内部的代码,还能作用于条件表达式,以及通过const变量间接引用的判别属性访问:

const f2 = ( obj: { kind: 'foo'; foo: string } | { kind: 'bar'; bar: number } ) => { const isFoo = obj.kind === 'foo'; if (isFoo) { obj.foo; } else { obj.bar; } };

这里把判别属性比较的结果存入const isFoo,在if (isFoo)分支内,编译器仍能推导出obj已被收窄为{ kind: 'foo'; foo: string },因此obj.foo可以安全访问。该文档同时强调了一个容易踩坑的限制:收窄只在const变量上生效,且被判别对象不能在函数体内被重新赋值。例如let isString = typeof x === 'string'后再收窄会报错;obj = obj这类函数体内的赋值也会中断收窄(详见 control-flow-analysis.md)。此外,条件表达式中最多分析五级间接引用。

穷尽性检查:用never让编译器兜底

判别联合的另一个杀手级特性是穷尽性检查(Exhaustiveness Checking)。仓库 exhaustiveness-checking.md 定义:穷尽性检查确保switch或if语句处理了判别联合的所有可能分支。其实现方式是借助never类型:

type Direction = 'up' | 'down'; const move = (direction: Direction) => { switch (direction) { case 'up': console.log('Moving up'); break; case 'down': console.log('Moving down'); break; default: const exhaustiveCheck: never = direction; console.log(exhaustiveCheck); // This line will never be executed } };

原理如下:

  1. 当switch覆盖了Direction的所有成员('up'、'down')后,default分支中的direction已被收窄为没有任何值的类型,即never。
  2. 将direction赋给类型为never的变量exhaustiveCheck,类型上是合法的(never可赋值给任何类型,反过来任何非never值都无法赋值给never)。
  3. 如果日后给Direction新增一个成员(如'left')而忘记在switch中处理,default分支里direction的类型将变成'left',const exhaustiveCheck: never = direction就会产生编译错误,提醒你补齐分支。

把这一模式套用到本文的area示例上,可以增强其健壮性:

type Shape = Square | Circle; const area = (shape: Shape) => { switch (shape.kind) { case 'square': return Math.pow(shape.size, 2); case 'circle': return Math.PI * Math.pow(shape.radius, 2); default: const exhaustiveCheck: never = shape; return exhaustiveCheck; } };

将来若新增type Triangle = { kind: 'triangle'; base: number; height: number }并把它并入Shape,编译器会立刻在default分支报错,提示area尚未处理三角形——这就是判别联合带来的"改一处、编译器帮你查全"的维护体验。

综合实战:用判别联合建模网络请求状态

将上述概念整合起来,一个典型的判别联合实战场景是建模异步请求的多种状态(在switch中逐类处理,并用never兜底保证未来扩展安全):

type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: string[] } | { status: 'error'; message: string }; const render = (state: RequestState) => { switch (state.status) { case 'idle': return 'Waiting for user action'; case 'loading': return 'Loading...'; case 'success': return `Loaded ${state.data.length} items`; // 此处 state 已收窄为 success 成员 case 'error': return `Error: ${state.message}`; // 此处 state 已收窄为 error 成员 default: const exhaustiveCheck: never = state; return exhaustiveCheck; } };

这里status就是判别属性(四个不同的字符串字面量),每个分支都能安全访问各自专属字段(data、message),且四个case全被覆盖后default中的state收敛为never。这种写法避免了大段if嵌套和运行时类型断言(as),让数据流在类型层面变得自解释。

使用判别联合的最佳实践

结合原文档示例与仓库相邻章节,总结以下实践建议:

  1. 判别属性必须是字面量类型:只有'square'、'circle'、'success'这类字面量(字符串、数字或布尔值)才能让编译器在编译期区分成员;使用普通string会让收窄失效。
  2. 判别属性最好语义化命名:kind、type、status、state都是常见命名,语义清晰即可;判别属性应放在对象成员中且各成员保持一致的键名与位置。
  3. 配合switch或if做等值收窄:switch (shape.kind)、if (shape.kind === 'square')都是合法触发点;比较结果存入const变量后可间接引用(需 TypeScript 4.4+,且对象不可在函数体内重新赋值,见 control-flow-analysis.md)。
  4. 用never做穷尽性兜底:在switch的default分支声明const exhaustiveCheck: never = value,新增联合成员而忘记处理时编译器会报错(见 exhaustiveness-checking.md)。
  5. 判别联合优于"裸联合 + 运行时判断":相比依赖in运算符或instanceof在运行时摸索成员身份(narrowing.md),判别联合把身份信息显式固化在数据模型里,可读性、可维护性与穷尽性保障都更强。

以上代码片段均可直接复制到 TypeScript 4.4+ 项目中编译运行。本书在不同语言版本中均有对应章节,中文版可参见 zh-cn/book/discriminated-unions.md,章节编排见 table-of-contents.md。把判别联合作为建模"多形态数据"的默认选择,配合收窄与穷尽性检查,就能在开发阶段消灭一大类类型错误,这也是本指南将其作为全书重点章节的原因。

  • 文档
  • 教程

【免费下载链接】typescript-book

The Concise TypeScript Book: A Concise Guide to Effective Development in TypeScript. Free and Open Source.

项目地址:https://gitcode.com/gh_mirrors/typ/typescript-book
点击查看免费下载

相关推荐

上一篇:QMQ生产环境案例:去哪儿网订单与搜索场景的深度应用
下一篇:REFramework-nightly 项目使用教程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询