
开头在 TS 的世界里never可能是最容易被忽略却又最关键的一个类型。最近社区里有个热梗叫“focusing never completed”意思是某个任务永远无法完成——这种“永不结束”的状态恰好就是never类型要表达的东西。它表示“永远不会出现的值”或者说“这种类型根本不存在任何可能的取值”。如果你写过类型守卫、做过穷尽性检查或者被条件类型折磨过never一定是绕不开的核心角色。这篇内容我不打算抄文档而是从实际项目出发聊透never的语义、用法和那些容易踩的坑看完你就能在代码里游刃有余地驾驭它。never不是一个高频出现的类型但它能在关键时刻提升代码的安全性让 TypeScript 帮你挡住那些隐藏的逻辑漏洞。适合所有使用 TypeScript 的开发者不管你是刚入门还是在做复杂的类型体操理解never都会让代码质量上一个台阶。1. 先搞清楚never 到底是什么1.1 从“永远不会有返回值”说起never这个概念初看非常反直觉。我们平时写函数要么返回一个具体值要么返回undefined总归会有一个结果。但在某些场景下函数根本不会正常结束——比如抛异常、死循环、或者process.exit()直接退出进程。这种“永远不会执行到函数的末尾”的情况TS 里就用never来表示。function throwError(message: string): never { throw new Error(message); } function infiniteLoop(): never { while (true) { // 永远在这里循环 } }注意这里的语义不是“没有返回值”而是“根本不可能有返回值”。因为函数一旦调用要么抛出异常中断执行要么永远循环不再继续。所以 TypeScript 会推断这两个函数的返回类型是never而不是void。第一次接触的时候很多人的第一反应是“这不就是 void 吗”其实完全不是。我给你打个比方void像是你去餐厅吃饭点了个菜结果端上来一个空盘子——菜是吃完了但至少盘子还在。而never更像是你点完菜之后厨房直接起火店都没了你压根就没吃到任何东西。一个是有返回值只不过值是undefined一个是永远不会有任何值出现。从类型系统的角度看never是 TypeScript 的“底类型”bottom type意思是它可以赋值给任何类型但任何类型都不能赋值给它。这种特性让它成了类型系统里最特殊的存在。1.2 never 和 void长得像但根本不是一回事很多教程里只用一句话区分never和void“void 表示没有返回值never 表示永远不会有返回值”但这句线话远不够。实际写代码时它们的差异在类型推导和赋值关系中体现得非常明显。// void 函数执行完毕返回 undefined function logMessage(msg: string): void { console.log(msg); } // never 函数抛出异常永远无法返回 function fail(msg: string): never { throw new Error(msg); }两者的关键区别void 可以被赋值let a: void undefined;是合法的undefined可以赋给void类型的变量。never 不能被赋值任何值都不能赋给never类型的变量你连undefined都塞不进去。下面这段代码就是最直观的对比let voidVar: void undefined; // 合法 let neverVar: never undefined; // 编译错误不能将类型undefined分配给类型never这个差异直接决定了它们的使用场景。void只用来标注函数的返回类型表示“你不需要关心这个函数的返回值”而never则更激进表示“这个函数根本不会给你任何返回的机会”。还有一个容易混淆的地方如果你在一个函数里写return;TS 推断出的返回类型是void而不是never。因为函数虽然没返回有效值但至少要“回来”。只有像throw、while(true)这种“连回来都做不到”的代码才是never。2. never 的三个核心应用场景2.1 穷尽性检查switch 分支的“安全网”平时我们写switch语句最怕的就是漏掉某个case。尤其是处理联合类型的时候比如一个status字段有success、error、loading三种状态如果你在switch里只处理了其中两个另外一个就会被默默忽略。这种 Bug 在代码 review 里很难发现但运行时往往带来意想不到的问题。never类型能完美解决这个问题。核心思路是在default分支里把变量赋值给一个never类型的变量。如果所有可能的类型都已经被前面的case处理完了那么这个default分支就永远不会执行赋值自然成立但如果漏掉了某个类型TS 就会在编译期报错提示你“这里可能还有没处理的情况”。type Status success | error | loading; function handleStatus(status: Status) { switch (status) { case success: console.log(请求成功); break; case error: console.log(请求失败); break; case loading: console.log(加载中); break; default: // 如果 status 没有被所有 case 覆盖 // 这里就会编译报错 const exhaustiveCheck: never status; break; } }这种做法叫“穷尽性检查”exhaustive check对大型项目来说非常实用。比如你定义了包含十几个类型的联合类型在switch里逐个处理时很容易漏掉一个。有了never这个“安全网”TS 会直接提醒你漏了哪个分支而不是等你上线了才被用户发现。2.2 类型守卫中的不可达分支类型守卫type guard的本质是让 TypeScript 在运行时通过条件判断来“收窄”类型。比如用typeof、instanceof、或者自定义的类型谓词函数把string | number缩窄到string或number。缩窄到最后如果所有分支都被排除了剩下的就是never——因为逻辑上已经不可能有任何值能通过所有的过滤条件。看这个例子function processValue(value: string | number | boolean) { if (typeof value string) { // value 是 string } else if (typeof value number) { // value 是 number } else if (typeof value boolean) { // value 是 boolean } else { // 走到这里value 的类型是 never // 因为 string | number | boolean 的候选已经被排除完了 // 如果未来有人往联合类型里加一个 symbol // 这里就会报错提醒你补全处理逻辑 const check: never value; } }这种写法比单纯用default分支强在哪强在它把“不可能”转变成了“可检查”。如果没有never你可能会忽略else分支或者在里面随便写点兜底逻辑。但现在 TS 会强制你注意这个位置如果value的类型集合发生了变化比如新增了一种symbol类型那么else分支里value就不再是never而是symbol直接赋值给never类型的变量就会报编译错误。这种保护和穷尽性检查是同一个模式但应用范围更广。switch只能处理字符串字面量联合类型而类型守卫配合never可以处理任意结构化的联合类型包括对象类型之间的区分。2.3 标记永不完成的异步操作回到“focusing never completed”这个热词在异步编程里“永不完成”也是一种真实存在的状态。比如一个 Promise 永远不会 resolve也不会 reject——这种情况在轮询、长连接、或者某些事件监听器里非常常见。function waitForever(): Promisenever { return new Promise((resolve, reject) { // 既不调用 resolve也不调用 reject // 这个 Promise 永远处于 pending 状态 }); }这种写法看起来很极端但它是真实存在的。比如一个 WebSocket 连接的心跳检测你需要在某个条件下才让连接结束或者一个任务队列只要进程不退出就永远不会“完成”。用Promisenever标注这些操作能让调用者一眼就明白这个异步任务不会正常结束不要指望它后面的代码能执行。真正的好处体现在类型推导上。如果你写了一个返回Promisenever的函数那么await它的结果也会被推断为never类型。这在写类型层面的控制流分析时非常有用能帮你排除掉“这个操作可能有结果”的假设让代码逻辑更清晰。3. 进阶玩法never 在类型编程里的魔力3.1 条件类型中的 never——过滤与排除条件类型是 TypeScript 高级类型的基础而never在条件类型里扮演了一个特殊的角色。原因在于当条件类型的分发distributive机制触发时never会被直接过滤掉。这一特性让never成了类型过滤的利器。看个经典的例子实现一个NonNullableT工具类型它用来从联合类型中剔除null和undefinedtype NonNullableT T extends null | undefined ? never : T; // 使用示例 type Test NonNullablestring | null | undefined; // Test 的值是 string // 推导过程 // string | null | undefined 被分发成 // string extends null | undefined ? never : string string // null extends null | undefined ? never : null never // undefined extends null | undefined ? never : undefined never // 最终结果string | never | never string因为never在联合类型里会“自动消失”string | never就是string所以被过滤的项最终会从结果中移除。这个特性在写自己的工具类型时非常常用。比如我想从一个对象类型中排除某些特定属性就可以用ExcludeT, U配合keyof来实现type ExcludeT, U T extends U ? never : T; interface User { id: number; name: string; email: string; } type UserKeysWithoutId Excludekeyof User, id; // 结果是 name | email原理就是keyof User展开成id | name | email然后每个 key 再走条件类型的分发逻辑命中的变成never被过滤掉剩下的保留下来。3.2 用 never 实现精确的工具类型never在工具类型里还能做更精细的控制。比如你想从对象类型中删除某些属性完全可以自己实现一个OmitByValue工具类型——根据属性的值类型来过滤属性键。type OmitByValueT, ValueType { [K in keyof T as T[K] extends ValueType ? never : K]: T[K] }; interface Config { host: string; port: number; debug: boolean; } // 删除值类型为 boolean 的属性 type ConfigWithoutBoolean OmitByValueConfig, boolean; // 结果是 { host: string; port: number; }这里用了as语法TypeScript 4.1 引入的键重映射配合条件类型判断属性值的类型是否匹配。匹配的就返回nevernever作为键名会让这个属性直接消失。这个技巧在开源的代码库里经常能看到比如一些 ORM 框架的字段过滤逻辑。再比如你要实现一个只保留函数类型属性的工具type FunctionPropertiesT { [K in keyof T as T[K] extends Function ? K : never]: T[K] }; interface Service { name: string; start(): void; stop(): void; version: number; } type ServiceMethods FunctionPropertiesService; // 结果是 { start(): void; stop(): void; }这一类工具类型核心思想都是“用 never 做键的占位符把不需要的属性过滤掉”。理解了 never 在条件类型中的分发行为这些高级类型写起来就会顺手很多。3.3 never 在联合类型中的“隐形”特性前面已经提到了never作为联合成员时会自动消失string | never等价于stringnever | number等价于number。看起来只是个小特性但它在类型推导的很多微妙场景里都在起作用。比如Promise.all的返回值类型推导const results await Promise.all([ fetchData(), Promise.resolve(42) ]); // results 的类型是 [Data, number]如果其中一个 Promise 的返回值是never它不会污染整个数组的推导结果const neverPromise Promise.resolve(never as never); const results await Promise.all([ Promise.resolve(hello), neverPromise ]); // results 推断为 [string, never]但你实际操作时 // 完全可以把它当 [string, ...] 来用还有一个实用场景是as const和never的组合。在switch的default分支里做穷尽性检查时TS 会把变量收窄成never这个变量在后续代码里完全不可操作——既不能读属性也不能调方法。这是它安全的原因一旦变成never任何操作都会是类型错误。“隐形消失”的特性在做条件类型推导时还可以避免多余的never干扰计算结果。比如type ExtractStringT T extends string ? T : never; type Mixed ExtractStringa | 1 | b | true; // a | never | b | never a | b没有“隐形消失”这个特性Mixed就会变成a | never | b | never不仅不美观而且在实际使用中任何涉及它的类型检查都更麻烦。现在它干干净净地展示出筛选后的结果这就是never在类型编程中最迷人的地方。4. 实战避坑这些错误我全踩过4.1 以为 never 和 unknown 是一回事never和unknown都涉及“无法确定类型”的语义但方向完全相反。我头回接触的时候就在这个问题上栽了跟头。一句话总结unknown是“任何类型的值都可能出现在这里”它是所有类型的“父类型”top type。never是“这里不可能有任何值”它是所有类型的“子类型”bottom type。举个直观的例子function process(input: unknown) { // input 是 unknown可以安全地传参给任何顶级变量 const copy: unknown input; // 合法 // 但不能把 unknown 直接赋值给 string const str: string input; // 编译错误 } // never 反过来 function neverFunc(): never { throw new Error(always throws); } // 可以把 never 赋值给任何类型 const num: number neverFunc(); // 合法 const obj: object neverFunc(); // 合法用大白话说unknown是“我不能确定你是什么所以所有类型都有可能”never是“我已经排除了所有可能所以什么类型都不是”。理解了这一点你就不会在unknown需要收窄的地方误用never也不会在标注不可达分支时误用unknown。4.2 在函数返回类型里错用 nevernever作为返回类型只应该用于“函数不可能正常返回”的场景。如果你的函数是一个正常的同步函数只是没有返回值那么应该用void而不是never。我见过不少刚入门的代码为了“追求严谨”把凡是没返回值的函数都标成never结果是一堆编译错误// 错的这个函数其实会正常返回 function parseConfig(configStr: string): never { if (!configStr) { throw new Error(empty config); } // 前面如果没抛异常函数会执行到这里但这里没写 return // 仍然正常结束返回 undefined } // 对的要么明确抛异常要么用 void 标注 function parseConfig(configStr: string): void { if (!configStr) { throw new Error(empty config); } }判断一个函数应不应该是never返回类型标准很简单如果存在任何一条控制流路径能让函数自然结束那它就不是never。只有所有可能的路径都抛出异常、死循环、或者退出进程时才是真正的never。如果你的函数有返回值只是个别情况下抛异常那返回类型应该是正常的类型比如string而不是never。因为调用者面对的是“要么拿到 string要么收到异常”这两种结果都通过正常机制传递没必要用never把返回类型封死。4.3 忽略了 never 的“不可赋值性”never是唯一一个“不能被赋值”的类型。很多初学者想用never来表示“这个变量以后可能会有值但现在没有”这是完全错误的用法。let user: never; user { name: Alice }; // 编译错误 user null; // 编译错误 user undefined; // 编译错误如果你想表示“这个变量还没有值之后才能赋值”应该用undefined配合可选链处理或者用null。never不是用来描述“暂时没有”的它是用来描述“永远不可能有”的。这个约束看起来严格但它在类型体操里帮了大忙。比如前面提到的OmitByValue工具类型正是靠never作为键值占位来剔除属性。如果never可以被赋值那这些过滤逻辑就会全部失效类型系统也就不安全了。4.4 条件类型分发里的坑联合类型遇到裸类型即使理解了条件类型的分发机制实际用的时候还是会有坑。最常见的错误是条件类型里如果左侧是一个“裸类型参数”裸类型指没有用[]包裹的类型参数那么这个条件类型会被分发到联合类型的每一个成员上。这个机制本身很有用但配合never时有时会出现意料之外的结果。type IsNeverT T extends never ? true : false; type Test1 IsNevernever; // 结果是 never而不是 true // 原因never 是空联合分发机制会把它当成一个空集合来处理 // 所以整个条件类型就变成了 never这个例子迷惑性极大我们明明是想判断一个类型是不是never结果IsNevernever返回的居然是never。原因就是类型参数的裸分发机制碰到never这个空联合时直接让条件类型“隐形消失”了。解决办法是让类型参数不要裸分发给它包一层方括号type IsNeverT [T] extends [never] ? true : false; type Test1 IsNevernever; // true type Test2 IsNeverstring; // false type Test3 IsNevernever | string; // false在需要编写关于never本身类型判断的工具类型时一定要记住这个坑。TS 官方文档都明确提过当条件类型作用于泛型时如果泛型是never结果就是never。所以很多开源库的工具类型里都会看到[T] extends [U]这种写法就是为了避免分发机制带来的意外。4.5 把 never 用在大规模联合类型里的性能考量这个坑是在大型项目里才体现出来的。如果项目里有非常复杂的联合类型比如几十甚至上百个成员频繁使用条件类型分发会拖慢 TS 编译速度。而never作为过滤条件参与分发时每个联合成员都会被单独遍历一次类型组合的复杂度会指数级上升。实际工作中的经验是如果联合类型的成员太多尽量用“有名联合”比如字面量类型或者枚举模式代替宽泛的条件类型推导。此外在编写复杂的工具类型时可以先把输入收窄到必要的范围不要一股脑把整个联合类型丢进去做条件分发。// 这种写法在联合类型很大的时候会比较慢 type SlowFilterT T extends { type: special } ? never : T; // 优化思路先提取关键信息减少分布计算的规模 type OptimizedFilterT T extends infer U ? U extends { type: special } ? never : T : never;这类优化的核心是减少“裸类型参数”的分发次数必要的时候用infer先提取类型合并计算路径。虽然写起来更绕但在大项目里编译速度的差异是能感受到的。5. 这些never技巧能让你写出更稳的代码5.1 用 never 强制接口实现者补全所有字段在写配置类接口或事件映射表的时候我们经常需要“某个字段已经穷尽”的约束。比如你定义了一个事件映射interface EventMap { click: MouseEvent; keydown: KeyboardEvent; scroll: UIEvent; } function dispatchEventK extends keyof EventMap(type: K, event: EventMap[K]) { // 在这里根据 type 处理对应的事件类型 }调用方可以安全地用dispatchEvent(click, new MouseEvent(click))这种形式调用TS 会校验第二个参数的类型必须和click对应。这种模式已经把事件类型和事件数据绑定了。想更进一步在switch里使用时把never作为兜底就能保证接入新事件类型时必须同步修改处理逻辑。5.2 在 Redux/状态管理里用 never 做拒绝合并的分支使用 Redux Toolkit 或 Vue 的状态管理时action.type的联合类型通常很大。如果你在一个 reducer 里处理所有 action 类型经常会有漏网之鱼。用default分支配合never做穷尽性检查就能在编译期暴露漏掉的 actiontype Action | { type: INIT } | { type: LOAD_DATA; payload: string } | { type: RESET }; function reducer(state: State, action: Action): State { switch (action.type) { case INIT: return initialState; case LOAD_DATA: return { ...state, data: action.payload }; case RESET: return initialState; default: // 如果以后加了一个新 action 类型这里会报错 const exhaustive: never action; return state; } }这种防护在状态管理项目里价值极高。状态管理本来就容易被不断增长的 action 侵蚀一个不注意就漏掉分支运行时才爆出“action 没被处理”的诡异 Bug。有了never兜底没有处理的新 action 在编译期就直接把你拦住。5.3 自定义工具类型时用 never 做隐式“空值”占位除了过滤联合类型、删除对象属性never还常用于类型计算里的“辅助占位”。比如我要定义一个DeepReadonlyT把对象类型的所有嵌套属性都变成只读。这里的核心逻辑是对于基础类型直接返回never对于对象类型递归展开。最后把never从结果里过滤掉就只剩下真正需要深层遍历的部分。type DeepReadonlyT { readonly [P in keyof T]: T[P] extends object ? DeepReadonlyT[P] : T[P]; };这里的递归已经用了T[P] extends object判断如果T[P]是never它会进入object分支吗不会。因为never extends object也是 false。所以DeepReadonly碰上never时会被原样返回。如果你希望某些属性被忽略就需要把判断分支里的never放在条件结果里利用“隐形消失”来清理输出类型。5.4 在类型体操里用 never 做“预期中的非法状态”最后分享一个我自己的实践经验在处理类型系统的“非法状态”时never是最合适的表达方式。比如你在写一个表单校验的类型type FormState | { status: idle } | { status: loading } | { status: success; data: string } | { status: error; message: string }; // 定义“当前状态的保留关键字”用 never 表示不存在 type StatusLookup { idle: never; loading: never; success: { data: string }; error: { message: string }; };这样设计的好处是当你通过状态名取类型时idle和loading不会携带多余的数据而success和error会带各自的载荷。这种映射模式是我在实际项目里总结出来的用来约束状态机的数据绑定避免在非法状态下访问不存在的字段。用never表示“这个状态下没有对应字段”比用undefined或者null更加严谨因为类型系统层面就杜绝了对这块数据的访问。6. 关于 never 的个人体会我个人在项目里用得最顺手的还是穷尽性检查这套组合拳。尤其是维护一个长期迭代的业务系统联合类型每天都在膨胀没有never兜底reducer 或者策略模式里的switch迟早会出现分支遗漏。每次 TS 编译报出“一个never类型的变量被赋值给了非never类型”的错误时我都很庆幸当初留了这一手。never的学习成本其实很低难点在于理解它的“底类型”定位。一旦想通了“为什么它能赋值给任何类型但什么都不能赋值给它”这个问题后面所有的技巧和坑都顺理成章了。如果非要选一个关于never的实用建议我会说不要在业务代码里刻意去制造never但一定要在类型守卫、switch、条件类型这些边界处理的位置主动利用它。它就是 TypeScript 给你的最后一道护栏好好地用它编译期就会替你挡掉一堆运行时才会暴露的麻烦。最后再分享一个小技巧当你调试一个复杂工具类型不确定某个分支的类型到底是什么时临时把它改成never看一眼报错信息。TS 会在“不能分配给 never”或者“never 不能分配给某类型”的报错里透露出当前类型的真实结构这比各种类型打印工具都来得直接。