尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TypeScript `never` 类型完全指南:从 Bottom Type 原理到穷尽性检查实战(TypeScript Deep Dive 解读)

TypeScript `never` 类型完全指南:从 Bottom Type 原理到穷尽性检查实战(TypeScript Deep Dive 解读) 教程【免费下载链接】typescript-book:books: The definitive guide to TypeScript and possibly the best TypeScript book :book:. Free and Open Source 项目地址https://gitcode.com/gh_mirrors/ty/typescript-book点击查看免费下载本文基于 TypeScript Deep Dive 开源书籍的 docs/types/never.md 章节编写并结合 docs/types/discriminated-unions.md、docs/types/typeGuard.md、docs/types/literal-types.md 及仓库 code/types/ 下的可运行示例进行纵深展开帮助读者真正理解never的语义并掌握其在穷尽性检查这一高频实战场景中的完整用法。never是 TypeScript 类型系统中一个容易被人忽略、却承担着关键职责的特殊类型它是类型系统中的bottom type底类型是控制流分析的自然产物也是实现编译期穷尽性检查Exhaustive Checks的基石。读完本文你将掌握never的定义与赋值规则、它与void的本质区别、TypeScript 对永不返回函数的类型推断策略以及用never让编译器在新增联合类型成员时自动报错的一整套实战方案。什么是never类型系统中的 Bottom Type编程语言设计领域存在一个bottom type概念它是一旦你开始做代码流分析code flow analysis就会自然而然涌现出来的结果。TypeScript 编译器会做控制流分析因此它需要一种可靠的类型来表达永远不会发生的事情这个类型就是never。从类型论的角度看never处于类型层级的最底端——它是所有类型的子类型但没有任何类型是它的子类型除它自身外。这一特性直接决定了我们后面会看到的赋值规则与穷尽性检查模式。never自然出现的两个场景never不需要你刻意标注在下面两种函数中 TypeScript 会自动将其推断为返回类型函数永不返回例如函数体内是死循环while(true){}函数永远执行不到结束函数总是抛出异常例如function foo(){ throw new Error(Not Implemented) }函数体永远不会正常走完此时foo的返回类型就是never。function infiniteLoop(): never { while (true) { // 永不退出 } } function alwaysThrows(): never { throw new Error(Not Implemented); }这两种情况是never最直观的来源不是没有返回值而是根本没有正常返回的那一刻。never的赋值规则只有never能赋给never既然never是底类型你自然可以主动使用它作为类型注解let foo: never; // Okay但随之而来的是一条严格规则——只有never类型的值才能赋值给never类型的变量。任何其他类型的值哪怕是number、string这样最常见的类型都无法赋给它let foo: never 123; // Error: Type number is not assignable to never反过来因为总是抛异常的函数返回类型是never这样的函数调用表达式就是一个合法的never值可以赋给never变量// Okay因为函数的返回类型是 never let bar: never (() { throw new Error(Throw my hands in the air like I just dont care) })();这条只有never可赋给never的规则是整个穷尽性检查模式能够成立的前提——它保证了只要某个位置期望never却出现了一个非never类型编译器就一定会报错。这正是我们在下一节要讲的核心用途。核心实战场景穷尽性检查Exhaustive Checksnever最重要的用途是让编译器帮你验证代码分支是否覆盖了所有可能性即穷尽性检查。在 never 上下文调用 never 函数考虑一个接收string | number联合类型的函数当typeof类型守卫type guard把string和number两个分支都处理完之后如果还有第三个分支即理论上不可能到达的分支普通函数在这里会因为不是所有代码路径都有返回值strict null checks 下或检测到不可达代码而报错。但如果你调用一个返回never的函数TypeScript 会明白这个分支的表达式类型是never在never上下文中调用never函数是合法的function foo(x: string | number): boolean { if (typeof x string) { return true; } else if (typeof x number) { return false; } // 如果没有 never 类型这里会报错 // - Not all code paths return a value (strict null checks) // - Or Unreachable code detected // 但正因为 TypeScript 理解 fail 函数返回 never // 所以它允许你在这里调用它用于运行时安全 / 穷尽性检查。 return fail(Unexhaustive!); } function fail(message: string): never { throw new Error(message); }这段代码的妙处在于编译期如果将来x的联合类型新增了第三种类型这个兜底分支就不再是never上下文return fail(...)会因类型不匹配而报错提醒你补上对新类型的处理运行时如果因为某些原因如any数据流、脏数据走到了这个分支fail会抛出一个明确的Error(Unexhaustive!)而不是静默返回一个错误的boolean。关于typeof、instanceof、in、字面量比较等类型守卫的细节可参考 docs/types/typeGuard.md 以及仓库中对应的可运行示例 code/types/typeGuard.ts。编译期穷尽性检查_exhaustiveCheck: never因为never只能被赋值为never你可以利用这一点做编译期的穷尽性检查。这一点与*可辨识联合discriminated union*高度相关完整的推导过程见 docs/types/discriminated-unions.md这里先看核心模式。先定义一个带字面量成员的可辨识联合字面量类型的基础知识见 docs/types/literal-types.mdinterface Square { kind: square; size: number; } interface Rectangle { kind: rectangle; width: number; height: number; } // 某人刚刚新增了这个 Circle 类型 // 我们希望 TypeScript 在任何需要处理它的地方给出错误 interface Circle { kind: circle; radius: number; } type Shape Square | Rectangle | Circle;假设area函数只处理了square和rectangle漏掉了新加入的Circlefunction area(s: Shape) { if (s.kind square) { return s.size * s.size; } else if (s.kind rectangle) { return s.width * s.height; } // 如果 TypeScript 能在这里给你一个错误是不是很理想 }解决方案非常简单在最后加一个兜底else分支并让该分支中的推断类型与never做兼容性检查。由于此时s已被收窄为Circle而Circle不是never编译器就会报出精确的错误function area(s: Shape) { if (s.kind square) { return s.size * s.size; } else if (s.kind rectangle) { return s.width * s.height; } else { // ERRORCircle is not assignable to never const _exhaustiveCheck: never s; } }这就强制你去处理新情况。补齐Circle分支后else中s被收窄为never检查重新通过function area(s: Shape) { if (s.kind square) { return s.size * s.size; } else if (s.kind rectangle) { return s.width * s.height; } else if (s.kind circle) { return Math.PI * (s.radius ** 2); } else { // 再次通过 const _exhaustiveCheck: never s; } }在switch语句中使用穷尽性检查同样的模式也可以放进switch的default分支function area(s: Shape) { switch (s.kind) { case square: return s.size * s.size; case rectangle: return s.width * s.height; case circle: return Math.PI * s.radius * s.radius; default: const _exhaustiveCheck: never s; } }与strictNullChecks的配合如果你启用了strictNullChecks配置说明见 docs/options/strictNullChecks.md上面的switch写法可能会触发 not all code paths return a value 的报错。解决办法是直接返回这个_exhaustiveCheck变量——由于它的类型是never把它作为返回值恰好能满足所有路径都有返回值的要求function area(s: Shape) { switch (s.kind) { case square: return s.size * s.size; case rectangle: return s.width * s.height; case circle: return Math.PI * s.radius * s.radius; default: const _exhaustiveCheck: never s; return _exhaustiveCheck; } }assertNever把检查收敛到一个函数里你还可以把走到这里就抛错的逻辑封装成一个小工具函数它接收一个never参数因此只能被推断为never的变量调用一旦函数体真的执行就抛出异常function assertNever(x: never): never { throw new Error(Unexpected value. Should have been never.); }配合area函数的用法如下interface Square { kind: square; size: number; } interface Rectangle { kind: rectangle; width: number; height: number; } type Shape Square | Rectangle; function area(s: Shape) { switch (s.kind) { case square: return s.size * s.size; case rectangle: return s.width * s.height; // 编译期新增 case 会得到编译错误 // 运行时出现意外值会得到运行时错误 default: return assertNever(s); } }这套双保险是社区中处理穷尽性检查的标准姿势编译期由never的可赋值性兜底运行时由抛异常兜底。never与void的区别Unit vs Falsum初学者最容易混淆的就是never与void——毕竟直觉上函数没有正常退出和函数没有返回值听起来很像。但两者在类型论上截然不同void是一个 Unit单元类型它表示函数确实正常返回了只是没有返回有意义的值返回值是唯一的那个值undefinednever是一个 falsum假、空类型它表示函数根本不可能正常返回连undefined都不存在。两者最直观的差异体现在可赋值性上void在未开启strictNullChecks时可以被赋给其他类型因为undefined可以被赋值给任何类型never永远只能被赋给never不能赋给其他任何类型。function returnsVoid(): void { // 正常返回只是没有返回值 } function returnsNever(): never { throw new Error(always throws); }一句话总结返回 nothing的函数返回void永不返回或总是抛错的函数返回never。类型推断为什么永不返回的函数声明默认推断为void一个有意思的细节是TypeScript 对永远抛错的函数的返回类型推断会因函数声明与函数表达式的不同而不同// 推断返回类型void function failDeclaration(message: string) { throw new Error(message); } // 推断返回类型never const failExpression function(message: string) { throw new Error(message); };你可以通过显式注解把函数声明的返回类型纠正为neverfunction failDeclaration(message: string): never { throw new Error(message); }为什么保留这种推断差异这种看起来不一致的行为是有意为之核心原因是与真实世界 JavaScript 代码的向后兼容性class Base { overrideMe() { throw new Error(You forgot to override me!); } } class Derived extends Base { overrideMe() { // 这里才是真正会返回的代码 } }如果Base.overrideMe被推断为never那么Derived中重写它的普通实现返回void就会因为never不能赋给void之外的类型而报错大量现存代码会被破坏。TypeScript 因此为函数声明保守地选择了void推断。真实世界的 TypeScript 开发者可以用abstract函数来避免这种设计见 docs/classes.md 中关于抽象类的讨论但这种推断策略依然为兼容性保留了下来。该行为与 TypeScript 官方 PR #8652。更多实战模式穷尽性检查在真实项目中的形态never的穷尽性检查不止适用于几何图形示例以下两种是真实项目中非常常见的应用形态均出自 docs/types/discriminated-unions.md。模式一Redux reducer 的动作类型收尾Redux 是重度使用这一模式的典型库。将官方 gist 加上 TypeScript 类型注解后Action是一个可辨识联合reducer中的switch天然受益于穷尽性检查import { createStore } from redux type Action { type: INCREMENT } | { type: DECREMENT } /** * 这是一个 reducer一个 (state, action) state 的纯函数。 * 它描述了 action 如何把 state 转换成下一个 state。 * 本例使用 switch 语句和字符串你也可以使用其他约定 * 如函数映射表的辅助函数。 */ function counter(state 0, action: Action) { switch (action.type) { case INCREMENT: return state 1 case DECREMENT: return state - 1 default: return state } } let store createStore(counter) store.subscribe(() console.log(store.getState()) ) store.dispatch({ type: INCREMENT }) // 1 store.dispatch({ type: INCREMENT }) // 2 store.dispatch({ type: DECREMENT }) // 1配合 TypeScript 使用你能获得对拼写错误的防护、更高的可重构性以及自带文档的代码——一旦有人为Action新增一个type成员所有遗漏该 case 的 reducer 都会在编译期收到never不匹配的报错。模式二回溯式版本化Retrospective Versioning假设你有一个数据结构DTOtype DTO { name: string }当你已经积累了一批DTO之后才意识到name这个字段名选得不好可以通过带字面量 number 版本号的联合来回溯式地给数据加版本把版本 0 标记为undefined在开启strictNullChecks的情况下一切都能自动工作type DTO | { version: undefined, // version 0 name: string, } | { version: 1, firstName: string, lastName: string, } // 更晚的时候 | { version: 2, firstName: string, middleName: string, lastName: string, } // 以此类推使用这种 DTO 时配合never穷尽性检查function printDTO(dto: DTO) { if (dto.version null) { console.log(dto.name); } else if (dto.version 1) { console.log(dto.firstName, dto.lastName); } else if (dto.version 2) { console.log(dto.firstName, dto.middleName, dto.lastName); } else { const _exhaustiveCheck: never dto; } }未来每新增一个版本编译器都会强制你在printDTO中补上对应的处理分支杜绝旧代码静默吞掉新版本数据的隐患。总结never在 TypeScript 类型系统中承担着不可替代的角色语义上它是 bottom type是控制流分析的必然产物表达永远不会发生赋值规则上只有never能赋给never这一条规则使得它成为编译期穷尽性检查的天然工具与void的区别void是 Unit有唯一的undefined值never是 falsum连值都不存在推断策略上函数声明默认推断为void、函数表达式推断为never这是为兼容真实世界 JavaScript 代码而保留的设计实战上_exhaustiveCheck: never、assertNever(x: never)等模式可以覆盖 Redux reducer、数据 DTO 版本化等大量场景把漏处理新类型从运行时故障提前到编译期错误。想要在本地验证文中所有示例可以直接使用仓库 code/types/ 目录下的示例代码如 code/types/typeGuard.ts 演示类型守卫的收窄过程并通过 code/types/tsconfig.json 所展示的编译选项其中strictNullChecks相关的行为说明见 docs/options/strictNullChecks.md来复现严格模式下的报错信息。建议结合 docs/types/typeGuard.md 的类型守卫知识、docs/types/literal-types.md 的字面量类型知识与 docs/types/discriminated-unions.md 的可辨识联合章节一起阅读形成对收窄 → 穷尽这一完整链路的系统性理解。赞分享教程【免费下载链接】typescript-book:books: The definitive guide to TypeScript and possibly the best TypeScript book :book:. Free and Open Source 项目地址https://gitcode.com/gh_mirrors/ty/typescript-book点击查看免费下载相关推荐Ruff 类型检查器ty的穷尽性检查Exhaustiveness Checking实战指南Ruff 类型检查器ty的穷尽性检查Exhaustiveness Checking实战指南 导读 穷尽性检查Exhaustiveness Checki开发工具Lint格式化静态分析CLITypeScript Deep Dive中文版从JavaScript到TypeScript的完美迁移指南TypeScript Deep Dive中文版从JavaScript到TypeScript的完美迁移指南 想要从JavaScript平滑过渡到TypeScri文档教程TypeScript 类型兼容性Type Compatibility完全指南从结构类型系统到变型Variance实战解析TypeScript 类型兼容性Type Compatibility完全指南从结构类型系统到变型Variance实战解析 本文基于 typescrip教程上一篇ChaiScript回调机制实现C与脚本双向通信的终极指南下一篇PNotify快速入门5分钟创建精美通知创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表