news 2026/9/24 15:17:34

The Concise TypeScript Book 精读:Discriminated Unions 判别联合类型完全指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
The Concise TypeScript Book 精读:Discriminated Unions 判别联合类型完全指南

The Concise TypeScript Book 精读:Discriminated Unions 判别联合类型完全指南

【免费下载链接】typescript-bookThe Concise TypeScript Book: A Concise Guide to Effective Development in TypeScript. Free and Open Source.项目地址: https://gitcode.com/gh_mirrors/typ/typescript-book

判别联合(Discriminated Unions)是 TypeScript 类型系统中用于表达"同一数据在不同形态间切换"的核心模式:它让联合类型中的每个成员通过一个公共的判别属性(discriminant)在运行时被唯一标识,从而使编译器能够在switch、条件判断等处自动收窄类型。本文基于《The Concise TypeScript Book》(discriminated-unions.md)展开,并贯通书中 Narrowing、Exhaustiveness checking、The never Type 与 Control Flow Analysis 等章节,读完你将掌握判别联合的定义方式、收窄原理、与穷尽性检查的组合实战,以及它和普通联合类型、switch(true)、类型谓词等相邻特性的边界与取舍。

从联合类型到判别联合

在深入判别联合之前,先回顾书中 Union Type 一节的定义:联合类型(Union Type)表示一个值可以是若干类型中的任意一种,用|符号连接:

let x: string | number; x = 'hello'; // Valid x = 123; // Valid

联合类型表达的是"或"的关系,但直接对联合成员做区分往往要依赖typeofininstanceof等运行时手段(详见 Narrowing)。当联合的成员都是对象时,更优雅、更可靠的方案就是判别联合。

根据 exploring-the-type-system.md 中的描述,判别联合又被称为tagged union(标签联合):通过给每个对象成员显式添加一个"标签"(tag)属性,来区分联合中的不同形态。书中给出的最小示例是使用type属性作为标签:

type A = { type: 'type_a'; value: number }; type B = { type: 'type_b'; value: string }; const x = (input: A | B): string | number => { switch (input.type) { case 'type_a': return input.value + 100; // type is A case 'type_b': return input.value + 'extra'; // type is B } };

可见,判别联合 = 对象联合 + 公共判别属性,判别属性通常是字符串字面量类型(Literal Types 一节中定义的'a' | 'b' | 'c'这类单值集合),因为字面量类型是"单个元素集合",天然具备唯一标识能力。

核心定义:判别属性与成员结构

原文档 discriminated-unions.md 给出了判别联合的权威定义与完整示例:

Discriminated Unions in TypeScript are a type of union type that uses a common property, known as the discriminant, to narrow down the set of possible types for the union.

其核心代码示例(书中原文,可直接复制运行):

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. 公共判别属性kindSquareCircle中都存在,且类型分别是不同的字符串字面量'square''circle'。它像"身份证号"一样,让两个成员在运行时可以被唯一识别。
  2. 成员类型互斥:每个成员携带各自特有的数据(sizevsradius),这些数据只在对应分支内合法。
  3. 联合整体type Shape = Square | Circle将成员组合成一个可整体传入函数的类型。

area函数无需任何typeof或类型断言,直接在switch (shape.kind)上做字面量比较,TypeScript 的控制流分析就会在每个case中把shape收窄为对应的成员类型,于是shape.sizeshape.radius的访问都是类型安全的。

判别联合的收窄原理:控制流分析

判别联合之所以"魔法般地"安全,其底层机制是 TypeScript 的控制流分析(Control Flow Analysis)。书中 control-flow-analysis.md 明确说明:

Control Flow Analysis in TypeScript is a way to statically analyze the code flow to infer the types of variables, allowing the compiler to narrow the types of those variables as needed, based on the results of the analysis.

即编译器会静态分析代码的执行路径,根据条件表达式的结果动态收窄变量的类型。该章节还特别指出(TypeScript 4.4 起)收窄可以作用于条件表达式与通过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.foo——因为obj没有被重新赋值,编译器能够追踪isFooobj.kind === 'foo'的等价关系。注意两个限制(书中原文强调):

  • 如果isFoolet声明而非const,收窄不会生效,obj.foo会报错;
  • 如果函数体内对obj有重新赋值(例如obj = obj;),编译器也会放弃收窄。

此外书中还给出一个实用提示:条件表达式中最多分析五层间接引用(Notes: Up to five levels of indirection are analyzed in conditional expressions),这是编译器为控制分析复杂度而设的边界。

判别属性 vs 其他收窄手段

判别联合并非唯一的收窄方式。Narrowing 一节系统列举了其他 TypeScript 原生收窄手段,理解它们与判别联合的差异有助于在正确场景选择正确工具:

收窄方式机制适用场景与判别联合的关系
typeof类型守卫检查 JS 内置类型基本类型联合(number \| string无法区分同为对象的不同形态
真值性收窄(Truthiness)检查 truthy/falsystring \| null等可空类型仅区分"有值/无值"
相等性收窄(Equality)===/!==等与字面量比较字符串字面量联合、switch 场景判别联合的 switch 本质就是相等性收窄
in操作符收窄检查属性是否存在对象联合但无公共判别属性可替代判别联合,但成员多时分支冗长
instanceof收窄检查构造函数类实例联合依赖类而非纯数据对象
判别属性收窄公共字面量属性 + switch/if数据对象的多形态建模本文主题

相等性收窄的典型例子(narrowing.md):

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

in操作符收窄适合没有公共判别属性、但各成员拥有互斥属性名的对象联合(书中 Dog/Cat 例子):

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'; } };

相较之下,判别联合的优势在于:判别属性集中、可读性强、与switch结合后每个分支的收窄完全由编译器保证,且天然支持后续要讲的穷尽性检查。当对象成员数量多、形态差异大时,判别联合通常是比in操作符更可维护的选择。

never组合:穷尽性检查(Exhaustiveness Checking)

判别联合最强大的实战组合是穷尽性检查。书中 exhaustiveness-checking.md 定义:

Exhaustiveness checking is a feature in TypeScript that ensures all possible cases of a discriminated union are handled in aswitchstatement or anifstatement.

其实现手段是借助never类型(The never Type 中定义: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 } };

原理:当switch覆盖了Direction的全部可能值后,default分支中direction已被收窄为空集(即never)。把direction赋值给never类型的变量是合法的。而一旦未来有人给Direction增加新值(如'left')却忘记处理,default分支里direction的类型就变成了'left',把它赋给never就会产生编译错误,从而在开发期就暴露遗漏。

书中 never-type.md 给出了更贴近生产实践的版本——在default中直接抛出异常:

type Direction = 'up' | 'down'; const move = (direction: Direction): void => { switch (direction) { case 'up': // move up break; case 'down': // move down break; default: const exhaustiveCheck: never = direction; throw new Error(`Unhandled direction: ${exhaustiveCheck}`); } };

这个模式配合判别联合使用效果最佳:给Shape增加一个Triangle成员时,所有switch (shape.kind)default分支会立即报错,提示你补全三角形面积计算逻辑。

判别联合的实战模式

模式一:函数参数的多形态处理(API 响应建模)

网络请求结果是最典型的判别联合场景。可以用kind(或statustype)标记success/error/loading等状态,让状态机在编译期即被约束:

type LoadingState = { status: 'loading' }; type SuccessState = { status: 'success'; data: string[] }; type ErrorState = { status: 'error'; error: Error }; type State = LoadingState | SuccessState | ErrorState; const render = (state: State) => { switch (state.status) { case 'loading': return 'Loading...'; case 'success': return state.data.join(', '); // state 已被收窄为 SuccessState case 'error': return `Error: ${state.error.message}`; } };

模式二:表达式求值器(递归数据结构的类型安全)

判别联合非常适合描述树形或递归结构(如 AST、配置树、DOM 节点),每个节点类型携带自己的子节点结构:

type Expr = | { kind: 'number'; value: number } | { kind: 'add'; left: Expr; right: Expr } | { kind: 'mul'; left: Expr; right: Expr }; const evalExpr = (e: Expr): number => { switch (e.kind) { case 'number': return e.value; case 'add': return evalExpr(e.left) + evalExpr(e.right); case 'mul': return evalExpr(e.left) * evalExpr(e.right); default: const exhaustive: never = e; throw new Error(`Unknown expression: ${exhaustive}`); } };

模式三:switch (true)窄化布尔条件链

从 TypeScript 5.3 开始,书中 exploring-the-type-system.md 提到的switch-true narrowing提供了一种用布尔条件替代繁琐 if/else 链的写法,与判别联合思路互补(都是"按条件收窄"):

function classify(x: unknown) { switch (true) { case typeof x === 'string': return `"${x.toUpperCase()}"`; case typeof x === 'number': return x > 0 ? 'positive' : 'negative'; case Array.isArray(x): return `[${x.length} items]`; default: return 'something else'; } }

判别联合的注意事项与边界

  • 判别属性必须覆盖所有成员:只要有一个联合成员缺少该属性,或判别属性类型不是字面量类型(而是string),TypeScript 就无法按该属性收窄,整个模式失效。
  • 判别属性建议使用const上下文下的字面量:如kind: 'square'这类写法在类型层面即是字面量类型;若对象由变量动态构造,需要使用as const或显式类型注解来锁定字面量类型(参见 Literal Inference 与 Type Widening 的相关讨论)。
  • 收窄只针对同一引用的变量:如 Control Flow Analysis 所述,若变量在函数体内被重新赋值,编译器会放弃基于判别属性的收窄;间接引用判别比较结果时,务必使用const
  • 判别属性最好保持"只读"语义:虽然类型层面可以用readonly kind: 'square'进一步约束,但关键是判别属性不应在运行时被随意改写,否则收窄结果与实际数据不一致(这属于运行时契约,编译器只能保证类型层面一致)。

与其他章节的关联脉络

判别联合并非孤立概念,它是本书类型系统主线的交汇点:

  • 前置基础:Literal Types(判别属性的类型来源)、Union Type(联合的构成)、Narrowing(收窄手段全集);
  • 底层机制:Control Flow Analysis(编译器如何收窄)、Type Predicates(无法收窄时的自定义守卫兜底,如value is string谓词);
  • 进阶组合:The never Type 与 Exhaustiveness checking(穷尽性保障)、Exploring the Type System(tagged union 的另一种表述)。

例如书中 type-predicates.md 展示的类型谓词,可用于判别属性无法覆盖的场景:

const isString = (value: unknown): value is string => typeof value === 'string'; const foo = (bar: unknown) => { if (isString(bar)) { console.log(bar.toUpperCase()); } else { console.log('not a string'); } };

当联合成员本身是unknown或缺少可判别信息时,自定义类型守卫是判别联合的有效补充。

小结

判别联合(Discriminated Unions / Tagged Unions)是 TypeScript 中用"公共字面量判别属性 + 联合类型"构建可收窄对象模型的成熟模式。它把运行时的分支判断与编译期的类型收窄统一起来:switch (shape.kind)既在运行时选择逻辑,又在每个分支让编译器自动推导出最精确的类型。当与never类型配合做穷尽性检查时,它还能在新增联合成员时主动暴露所有未处理的分支,把"漏写 case"从运行时事故变成编译期错误。掌握判别联合,是驾驭 TypeScript 类型系统、写出高可维护领域模型的关键一步。

【免费下载链接】typescript-bookThe Concise TypeScript Book: A Concise Guide to Effective Development in TypeScript. Free and Open Source.项目地址: https://gitcode.com/gh_mirrors/typ/typescript-book

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 15:16:50

大麦抢票自动化:从详情页到提交订单,把整个流程压进10秒

大麦抢票自动化:从详情页到提交订单,把整个流程压进10秒 【免费下载链接】ticket-purchase 大麦自动抢票,支持人员、城市、日期场次、价格选择 项目地址: https://gitcode.com/GitHub_Trending/ti/ticket-purchase 你有没有过这种体验…

作者头像 李华