
先从一个挺有意思的现象说起很多刚接触 TypeScript 的同学第一次看到never类型都会觉得它“没什么用”——既不能赋值也几乎没在业务代码里直接用过。但如果你翻过一些库的源码或者被某个奇怪的报错折磨过就会发现never频繁出现在类型推断、条件类型、联合类型过滤这些地方。最近还有个热词叫 “focusing never completed”用来调侃某人一直聚焦却永远收不了尾。这个状态映射到类型系统里其实就是never的语义一件事情永远不会完成。这篇文章我想把 TypeScript 里的never类型彻底讲透从底层层级到实际项目中的落地方案再到那些容易让你怀疑人生的疑难杂症一次性梳理清楚。无论你是刚接触 TypeScript 的初学者还是写了很久但一直被类型报错困扰的开发者这篇文章都适合你。1. 先搞懂 never 的底层语义它究竟代表什么很多教程一上来就告诉你“never 表示永远不会发生的值”这句话没错但太抽象了。我在实际调试类型问题的时候发现真正让人困惑的点不在定义而在“它到底处在类型系统的哪个位置”“它和其他类型是怎么协作的”。这些搞明白了后面所有的用法都能顺理成章地理解。1.1 从“focusing never completed”说起永不完成的状态“focusing never completed”这个表达很有意思它描述的是一个持续聚焦、但永远不会有完成态的情况。对应到 TypeScript 的类型世界never描述的正是这种“永远没有结果”的状态。举个例子一个函数内部抛出异常函数的调用方永远拿不到返回值——函数执行到一半就中断了。再比如一个函数内部是死循环它永远不会执行到 return 那一行。这两种情况从类型系统的视角看函数的返回类型都是“不可能出现的结果”所以 TypeScript 用never来标记它们。这里有个非常关键的理解角度never不是一个“真实存在”的值类型它是一个“空”的集合。你可以把类型理解为“所有可能值的集合”比如string是所有字符串的集合number是所有数字的集合而never是空集合——里面什么都装不下。既然是空集合自然不可能存在一个变量真的被赋值为“never 类型的值”它只可能在类型层面的推导里出现。理解了“空集合”这个概念很多特性就能讲通了。比如空集合是所有集合的子集所以never是所有类型的子类型。再比如把空集合和任何集合做并集结果还是那个集合本身所以string | never最终会被简化成string。这些看起来“神奇”的规则本质上都是集合运算的自然结果。1.2 never 在类型系统中的位置底层类型与顶层类型在 TypeScript 的类型体系里有两个特殊的类型值得放在一起看unknown和never。它们分别处于两个极端。unknown是顶层类型它是所有类型的父类型。任何类型都能赋值给unknown比如const a: unknown hello没问题const b: unknown 123也没问题因为所有值都属于“未知值”的范畴。反过来unknown不能直接赋值给其他具体类型你必须先用类型守卫或断言收缩范围。never是底层类型它是所有类型的子类型。这意味着never可以赋值给任何类型比如const c: string u只要u的类型是never这个赋值是合法的。因为空集合是任何集合的子集从类型理论上是自洽的。这两个类型恰好是对偶的unknown包含一切never什么也不包含。实际开发中unknown常用于处理不可预知的外部数据而never常用于标记不可达逻辑或做类型运算。很多复杂的类型体操代码底层都是在利用这一对“顶”和“底”的性质来构造。这里还想多说一句any和这两个类型都不一样。any是类型系统里的一扇“逃生门”它会关闭类型检查让自己既能当父类型又能当子类型。虽然用起来方便但代价是丢失了类型安全所以能避免就尽量避免用unknown配合类型守卫往往更安全。1.3 never、void、undefined 和 null四者之间的边界never最容易和void混淆其次容易和undefined、null混淆。我在代码评审里见了无数次把never写成void的情况所以这里专门把界限梳理一下。void表示“函数没有返回值”。但它不代表“函数永远不会结束”恰恰相反它代表“函数正常执行完了只是没有返回任何有用值”。比如function logMessage(message: string): void { console.log(message); }这个函数执行完就是结束了只是返回了undefined。用void标注返回类型意味着“这个返回值你不必关心”。never则不同它表示“函数根本走不到结束”。比如function throwError(message: string): never { throw new Error(message); }这个函数一旦被调用执行流就中断了后面所有代码都执行不到。所以never比void更强一级。undefined是一个具体的值类型表示“变量已声明但值未定义”。null表示“有意的空值”。它们都是“存在”的只是内容为空而never是“不存在”。区别可以这样记类型集合含义典型场景void有值但无实际内容函数正常结束无返回值undefined有值值为 undefined未初始化的变量、可选属性null有值值为 null刻意设置为空never空集合没有任何值抛异常、死循环、不可达分支实际开发里如果你发现某个函数是“永远执行不完”的返回类型标注never才是准确的。如果你标成voidTypeScript 不会报错但它丢掉了“这段代码之后不可达”的语义后续的类型收窄也会受影响。2. 为什么需要 never三个必知的核心应用场景理解了never是什么之后接下来要回答“为什么我们需要它”。我梳理了开发中最常用的三个场景标记永不返回的函数、做穷尽性检查、参与联合类型与条件类型的运算。每一个都有不可替代的价值。2.1 永不返回的函数throw、死循环与进程退出先说不返回函数。最常见的三种情况是抛异常、死循环、进程退出。抛异常已经是老生常谈了这里不再赘述。死循环的例子是function infiniteLoop(): never { while (true) { // 业务逻辑 } }有些同学会问死循环为什么要单独标注never如果我不标TypeScript 会自动推断吗实际上TypeScript 对直接的while(true)循环如果函数没有其他路径返回它会自己推断成never。但如果你在循环里写了一些条件分支推断就有可能变成void或者number | undefined。所以显式标注never是一种“意图声明”这个代码块的语义就是永不结束。进程退出的场景在 Node.js 里非常常见function exitProcess(code: number): never { process.exit(code); }process.exit调用之后当前进程的代码不会继续执行所以返回类型标注never是准确的。这里有一个非常实用的小技巧在一个函数里如果某个分支调用了返回never的函数TypeScript 会认为这个分支之后的所有代码都是不可达的从而帮你做类型收窄。比如function assert(condition: boolean): never { throw new Error(断言失败); } function divide(a: number, b: number): number { if (b 0) { assert(false); // 这里返回 never } // TypeScript 知道走到这里时 b 一定不是 0 return a / b; }这样写即使assert内部并没有真正执行返回类型系统也已经明白了“b 0 的分支结束后后面代码不会再执行”因此a / b是安全的。这比手动写一大段类型守卫更直观。2.2 穷尽性检查switch/case 里的最后一道防线穷尽性检查是我个人认为never最有价值的实际应用。它解决的是一个每天都在发生的问题你定义了一个联合类型写了一个 switch 分支处理它后来往联合类型里加了一个新成员你希望编译器能立刻提醒你“这个新成员还没有被处理”。做法是写一个专门的assertNever函数function assertNever(value: never): never { throw new Error(意外的值: ${value}); }然后在 switch 的 default 分支里调用它type Shape | { kind: circle; radius: number } | { kind: square; side: number } | { kind: triangle; base: number; height: number }; function getArea(shape: Shape): number { switch (shape.kind) { case circle: return Math.PI * shape.radius ** 2; case square: return shape.side ** 2; case triangle: return (shape.base * shape.height) / 2; default: return assertNever(shape); } }这段代码的精妙之处在于如果有一天你往Shape联合类型里加了一个{ kind: rect; width: number; height: number }TypeScript 会在 default 分支处立刻报错因为shape的类型变成{ kind: rect; ... }它不再是never传不进assertNever。这就等于在编译阶段就给你拉响了警报。我在项目的业务代码里大量使用这个模式。尤其是处理接口返回的多种状态时每新增一种状态编辑器就会把所有相关的 switch 全部标红逐个处理完后红点消失心里特别踏实。这种“编译器帮你兜底”的感觉是动态语言完全给不了的。2.3 联合类型里的“通配符”用 never 做类型过滤第三个核心应用场景是联合类型运算。never在联合类型里相当于一个“通配符”或“透明元素”它会出现但最终会被消除。举一个最直观的例子type Result string | number | never; // 结果为 string | numbernever 被自动移除这个行为背后的原因是集合运算空集和任何集合取并集结果都是该集合本身。因为空集不增加任何可能性所以 TypeScript 的类型简化器会把never从联合类型中直接剔除。这个看似微不足道的特性是许多实用工具类型的实现基础。比如Exclude这个内置工具类型它的作用是“从联合类型 T 中剔除 U 中的成员”。举个例子type T Excludea | b | c, b; // 结果为 a | c它的内部实现就是利用条件类型对联合类型做分发然后让匹配到的成员变成never通过联合类型自动消除never来达到过滤效果。也就是说Exclude本身其实就是在“制造”一批never再交给类型系统去优化掉它们。如果看不明白这段实现没关系下一部分我会详细拆解条件类型与never的配合那里你会看到never在类型运算里真正的主力角色。3. never 类型实操从推断、断言到条件类型细节这一部分我打算直接写代码。毕竟never很多行为特别反直觉光靠理论推导容易记错不如直接上实操边跑边看结果。3.1 什么时候类型会被自动推断成 never先整理一个清单列出 TypeScript 在实际推断中会产生never的常见位置1. 永不返回的函数。如前所述函数体内只有throw、死循环、process.exit等情况时返回类型被推断为never。2. 类型守卫收窄后的不可能分支。比如function check(value: string | number) { if (typeof value string) { // value 是 string } else if (typeof value number) { // value 是 number } else { // 这里的 value 是 never } }string | number排除了string和number之后剩下的集合是空集所以value的类型是never。这也是我们能在 default 分支调用assertNever的前提。3. 某些泛型运算的结果。当泛型参数被配置成互相冲突的约束时结果可能落到never。例如function restrictT extends string | number(value: T boolean): never { return value as never; }这属于比较偏门的写法但在复杂库的源码里偶尔能看到。4. 字面量类型联合中的不可能交集。比如a b的交集是never因为没有任何字符串能同时是a又是b。交叉类型取到空集时结果就是never。平时开发中我遇到最多的其实是第 2 种——分支收窄走到了“不可能的区域”此时编辑器会建议你处理一下或者忽略。不要直接忽略这正是逻辑可能存在漏洞的信号。3.2 条件类型里的隐藏规则裸类型参数与分发行为条件类型是never最容易“翻车”的地方。很多人一遇到形如T extends U ? A : B的类型就懵再加上never一掺和结果更加扑朔迷离。先看一个最简单的条件类型type IsStringT T extends string ? true : false; type R1 IsStringstring; // true type R2 IsStringnumber; // false这很容易理解。但如果你传入nevertype R3 IsStringnever;直觉上never是string的子类型所以T extends string应该成立结果应该是true。但真实的结果是never。这个反直觉的结果来自 TypeScript 条件类型中的一个机制当T是一个裸类型参数时条件类型会对T进行分发。如果T是联合类型会把联合成员一个一个分发后再合并。比如type R4 IsStringstring | number; // IsStringstring | IsStringnumber true | false boolean分发需要遍历联合类型的每一个成员。而never是空联合也就是说它没有任何成员可以遍历所以整个条件类型直接短路结果是never。这个特性在写工具类型时特别容易让人疑惑。比如type DistributiveT T extends any ? { value: T } : never; type Item Distributivestring | number; // { value: string } | { value: number }但如果你传入nevertype Empty Distributivenever; // never这不是“错误”而是分发机制对空联合的自然结果。如果你想避开分发让条件类型老老实实地“作为一个整体”判断把类型参数用中括号包一层就行type NonDistributiveT [T] extends [string] ? true : false; type R5 NonDistributivenever; // true因为[never]是一个数组类型而never作为数组的元素不会再触发分发逻辑这时候never extends string的子类型关系才真正生效。这个技巧在复杂类型体操里非常常用。3.3 自己实现一套 Exclude 和 Extract 加深理解理论说得再多不如自己手写一遍。我当年真正理解never在条件类型中的作用就是因为自己写了一遍Exclude和Extract。先看ExcludeT, Utype MyExcludeT, U T extends U ? never : T;它把T里的每一项拿出来如果T的子成员能赋值给U就替换成never不能赋值给U的保留自己。最后never会被联合类型自动吸收掉。举个例子type R MyExcludea | b | c, a | b;分发过程是这样a extends a | b成立结果为neverb extends a | b成立结果为neverc extends a | b不成立结果为c合并得到never | never | c自动简化成c。这个结果完全符合预期。再看ExtractT, Utype MyExtractT, U T extends U ? T : never;Extract是保留能赋值给U的成员剔掉其他。分发过程a extends a | b成立保留ab extends a | b成立保留bc extends a | b不成立结果为never合并得到a | b。通过这一正一反两个例子你就能直观体会到never在里面扮演的“筛选漏斗”角色。从这个实现里还能引申出另一个技巧如果你想让Exclude支持非联合类型比如某个泛型整体不要直接对 T 分发而应该先做判断或者用数组包裹的方式阻止分发。实际使用内置工具类型时我们传进去的往往是联合类型所以默认的分发行为正是我们想要的。另外我自己写工具类型时有一个习惯把never的用例一起写上测试因为很多类型体操的边界条件都藏在never的行为里提前锁死能少踩很多坑。4. 项目实战与常见坑我踩过的 never 相关雷区最后这部分我想把never放回真实项目语境里分享几个实际代码案例和排查思路。前面讲的是“怎么用”这里讲“怎么用好、用对、用不炸”。4.1 在真实项目中用 never 构造更安全的代码我在公司前端项目里最常用到never的地方是处理接口返回状态。比如一个订单的流转状态可能有“待支付”“已支付”“已发货”“已完成”“已取消”每种状态对应不同的操作按钮和文案。传统写法可能是一个大的switch加各种if判断最后用default兜底。但只要你兜底一次下次加新状态时你就容易漏掉逻辑。我之前接手过一个老项目里面有个订单状态处理函数default分支直接return null结果新加一个“退款中”的状态后列表页面按钮区域大面积空窗我排查了好几个小时才找到根因。后来我引入了assertNever模式type OrderStatus | pending | paid | shipped | completed | canceled; function getOrderAction(status: OrderStatus): string { switch (status) { case pending: return 去支付; case paid: return 查看物流; case shipped: return 确认收货; case completed: return 再次购买; case canceled: return 重新下单; default: // 如果新增状态这里立刻报错 return assertNever(status); } }加状态的那天编译报错直接把我们引到了每个对应函数里改起来又快又稳。这种“把人为遗漏变成编译错误”的思路值得推广到任何联合类型驱动的业务分支里。另外在 redux 这类状态管理库的 reducer 里never也特别好用。action 的 type 如果被收窄到一个不可能的分支default 分支里action的类型就会变成never调用assertNever(action)就能在每次新增 action 类型时强制你更新所有 reducer。4.2 常见坑排查类型扩大、联合丢失与报错定位实战中我遇到的关于never的坑总结出来大概有三类。第一类never被意外扩大成unknown或其他类型。这种情况常见于泛型默认值和条件类型的返回值。比如type ResultT T extends string ? string : never; function wrapperT(value: T): ResultT { if (typeof value string) { return value; } throw new Error(); }如果你在某个分支里直接return value但当前分支的类型是never你可能会想“never 能赋值给任何类型应该没问题”。但实际报错往往不是你预期的那样因为 TypeScript 有时会把泛型推断成string | never经过去掉never后变成string最后 OK而有时因为分发机制的作用整个条件类型的结果会是never于是你的函数根本没有合法的返回值路径。遇到这种报错不要急着加as any先理清条件类型的分发路径。第二类联合类型成员意外丢失。有时候一个联合类型因为混入了never某个分支被 TypeScript 优化掉了导致后续判断代码永不执行。比如type Status active | inactive | never;由于never被吸收Status实际变成了active | inactive。如果你的代码里有一个专门针对“never状态”的分支那么这个分支其实是死代码。排查这种问题可以先把类型打印出来看看。第三类报错信息里出现 isn’t assignable to type ‘never’。这恐怕是 TypeScript 新手最熟悉的报错之一。它通常出现在数组处理中。比如const result []; result.push(hello);因为result被推断成any[]不会报错但如果你显式声明const result: never[] [];那就没法往里 push 任何东西因为数组里的元素只能是never类型而never是空集。还有一种常见情况使用Array.prototype.reduce时初始值推断不对导致累加器变成never。比如const numbers [1, 2, 3]; const total numbers.reduce((acc, num) acc num, 0);如果初始值写成了null或者某个不兼容的类型acc可能被推断成never然后你会在acc num处看到报错。解决办法是给初始值一个明确的类型标注。4.3 几个实用小技巧用 never 做调试和约束除了在业务逻辑中用never我还会拿它做很多事情这里挑几个特别好用的分享。第一个技巧利用never给对象类型加“禁用”约束。比如你想设计一个 API 参数对象其中mode为fast时不允许传入retryCountmode为safe时retryCount必填。这种交叉约束可以用一个“互斥”类型来做type ExclusiveT, U T { [K in keyof U]?: never }; type FastMode { mode: fast }; type SafeMode { mode: safe; retryCount: number }; type Option ExclusiveFastMode, { retryCount?: number } | SafeMode;这里{ [K in keyof U]?: never }把retryCount变成可选的never类型意思是“即使你写了也会报错”。这就实现了“存在即非法”的效果。这类写法在严格设计 SDK 参数时非常实用。第二个技巧用never排除函数中的某个重载。函数重载中如果你想让某个类型在特定场景下不可用可以在重载签名中直接写never参数。function format(input: string): string; function format(input: number): string; function format(input: never): never; function format(input: string | number): string { return String(input); }虽然这个例子有点刻意但它能说明never在重载解析中的作用当参数类型既不是string也不是number时匹配到never签名返回类型也是never表示“这种调用不合法”。第三个技巧调试时确认某个类型是never。我经常在复杂工具类型里临时写一个判断type IsNeverT [T] extends [never] ? true : false;注意这里必须用数组包裹不能用T extends never因为后者会触发分发机制结果永远是never而不是true。这个IsNever工具类型我几乎每次排查never相关问题时都会用到也建议你直接放进项目的类型工具文件里。最后再分享一点个人心得说句实话never是我在 TypeScript 里花了最久时间才“接纳”的类型之一。早期看到它就觉得它是语法糖、是边缘货直到有次在 reducer 里漏处理了一个新状态、上线后出了故障才真正意识到“让类型系统替你堵漏洞”有多重要。从那以后我写联合类型驱动的分支逻辑时几乎把assertNever当成了标配。如果你现在正被某个never相关的报错折磨我的建议是先别慌把报错里的类型展开看看它是作为一个独立的返回值、一个数组元素、还是一个条件类型的分发结果出现的。只要能定位到它出现的上下文九成以上的问题都能理清楚。最后留一个可以在项目里直接抄作业的工具类型集合type IsNeverT [T] extends [never] ? true : false; type AssertNeverT [T] extends [never] ? true : { error: Expected never but got a real type }; function assertNever(value: never): never { throw new Error(Unexpected value: ${JSON.stringify(value)}); }把assertNever放到你项目的utils里以后写 switch 的 default 分支、写穷尽性判断、写状态机处理都用它兜底。一段时间后你会发现自己犯低级状态遗漏错误的次数大幅度下降。这就是never真正的价值一个看似什么都装不下的类型却是类型系统里最可靠的哨兵。