
如果你写TypeScript肯定迟早会被any、unknown、never这三个类型卡住——面试里常问日常写代码也躲不掉。说实话我第一次看到unknown这个名字的时候脑子里只有一个想法这不就是any换了个马甲吗后来写多了才明白这三个类型根本不是三个相似的东西而是三种完全不同的设计意图。这篇文章不聊太悬的理论就从实际使用出发把这几个类型的行为、差异、应用场景以及我在项目里踩过的坑一次说清楚。不管你是刚开始学TypeScript还是写了一段时间但总搞混这三个类型这篇应该都能帮你把思路理顺。1. 先搞懂一件事类型系统到底在解决什么问题1.1 大多数困扰源于没看清前提想要真正理解any、unknown、never的差异第一步不是背定义而是先想明白TypeScript的类型系统到底在干什么。在JavaScript的世界里一个变量可以随时从数字变成字符串再变成一个对象这种动态特性写起来很爽但项目大了之后各种运行时才爆炸的问题就会让人非常头疼。TypeScript做的事情本质上是在代码运行之前先对值的形状做一次检查。这里的值是运行时的真实数据而形状就是类型。如果我们把一个变量标注为string那在编译期TypeScript就会确保你只往这个变量里放字符串也只会对字符串做操作。这样很多低级错误根本不会跑到运行期才被发现。这个检查机制在真实开发里的表现就像写字楼门口的门禁每个人进门前都要亮一下工牌。工牌上写的是程序员还是运维决定了你往哪里走。类型系统就是在代码层面给这些值发工牌。但是问题来了有些值的类型特别明确比如const name 张三这肯定是string可有些值你拿到手的时候并不知道它是什么来路比如接口返回的数据、用户输入的内容、外部系统发给你的回调参数。这时候类型系统该怎么办答案就是让我们用类型把这种不确定表达出来。1.2 三个类型其实是三种回答围绕不确定这个主题any、unknown、never分别给出了三种完全不同的回答any的回答是这个值可以是任何东西我不打算检查它我接受一切操作也不会限制任何赋值。听起来很自由但代价是类型系统对这个值彻底失明。unknown的回答是这个值的类型我不知道所以我也不能让你随便用它。你可以把任何东西塞给我但你想用我之前必须先证明它是什么类型。这是安全版的不确定性。never的回答更特殊它不是不确定而是根本不存在。它代表的是一个空集——这个类型下面一个值都没有。你没办法把一个真实的值塞给never但你可以把这个类型赋值给任何其他类型。理解了这个大前提再看三者的区别就会轻松很多。接下来我们一个个拆开讲。2. any最自由的类型也是最大的坑2.1 any的真实行为先看一段代码let data: any 42; data hello; data { name: 张三 }; data.getName(); data.some.property.deeply.nested; const count: number data;用any声明的变量想赋什么值就赋什么值想调什么方法就调什么方法哪怕这个方法的调用在运行时百分之百会报错编译器也一句话不会多说。它还可以赋值给任意其他类型比如上面的const count: number data完全不会报错。这套行为背后any实际上是一个顶级类型它和所有类型都兼容既能接受任何类型的值也能被赋值给任何类型。用一句话概括any让人失去了所有类型约束。它就像是把写字楼大门的门禁卡直接掰断了任何人都可以进出。更坑爹的是any还有传染性。当你把一个any类型的变量传给一个函数时函数接收到的参数也会变成隐式的any当你把一个any赋值给一个具体类型时那个具体类型瞬间就失去了保护。这种传播方式会让类型系统像多米诺骨牌一样整片倒下。2.2 我在老项目里看到的any灾难前几年我接手过一个老项目那是一个典型的为了上线先疯狂赶进度的项目。打开代码满屏的any类似于function saveOrder(order: any) { order.items.forEach((item: any) { item.price discount(item.price); }); }这种写法在开发期确实省事一行类型都没写什么编译错误都没有。但后来产品要求改价规则把discount的参数类型从number改成了一个新的PricingItem对象。因为item是any编译器完全不知道discount的调用方式变了所有调用方安安静静地通过了编译。结果一上线用户在结算页大面积报错才把这个问题炸出来。这就是any最可怕的地方它把所有错误推迟到了运行时而且是在用户手里爆发的运行时。你在编辑器里省下的每一分钟都会在未来的某个深夜用两倍的时间还回去。2.3 什么时候可以选any写到这里我也得替any说句公道话。它并不是完全一无是处有些场景你确实绕不开。第一类场景完全没有类型声明的第三方库。老项目中常见的比如一些纯JavaScript封装的原生SDK作者没有提供.d.ts类型声明社区里也没有现成的类型包。这时候直接用一个最低限度的类型描述成本可能比收益还高只能先用any顶着。第二类场景从JavaScript项目往TypeScript迁移的过渡期。一个大项目不可能一夜之间把所有类型补全渐进式迁移的过程中先给关键模块写类型、非关键模块暂时用any是完全可以理解的策略。第三类场景高度动态的运行时数据结构实在无法预期。比如你在做一个插件系统插件的输入输出完全由外部脚本决定硬要写一个静态类型反而是不诚实的。但即便在这类场景里我的建议依然是先试unknown再用any。any应该是最后的手段而不是默认选择。另外日常开发中请务必开起tsconfig.json里的noImplicitAny。这个选项打开后凡是隐式推导成any的地方都会直接报错逼着你去给变量加类型哪怕是一个不完美的类型注释也能让代码的可维护性上一个台阶。不过打开noImplicitAny之后你会立刻遇到一个非常常见的问题函数参数没有显式标注类型时会报Parameter xx implicitly has an any type。很多初学者在这一步直接被劝退但其实解决方案很简单给它一个类型就好了如果实在不知道参数是什么用unknown接住再用类型收窄去判断这样反而更安全。3. unknown强制你验证的未知数3.1 同样是任意值unknown为什么被称为类型安全的any当TypeScript 3.0发布时official文档里把unknown描述为类型安全的any。为什么这么说因为它解决了any最大的问题——不做检查。先看unknown的基本规则let value: unknown 42; value hello; value { name: 张三 };从能接受任何类型的赋值这个角度看unknown和any一模一样。但是当你拿到一个unknown类型的变量后画风突变const str: string value; // 报错Type unknown is not assignable to type string value.toUpperCase(); // 报错Object is of type unknown这段代码被编译器毫不犹豫地拦住了。原因很简单既然你告诉我这个值可能是任何东西那我怎么能允许你直接把它当成字符串使用万一它是数字呢万一它是对象呢unknown类型的设计意图就是我允许你说我暂时不知道它是什么但我禁止你在不知道的情况下随便动弹它。这就像你在门口看到一个没有佩戴工牌的陌生人就算他手里拎着一台电脑你也不能直接放他进机房。你必须先让他证明自己的身份——在TypeScript里这个证明身份的过程就叫类型收窄。3.2 类型收窄使用unknown的必修课按照官方说法类型收窄Type Narrowing是一个从宽泛类型缩小到具体类型的过程。对unknown来说收窄不是一项优化而是使用它的前置条件。常见的手段有这么几种。第一种用typeof做基础类型收窄function process(value: unknown) { if (typeof value string) { // 这里value被收窄为string console.log(value.toUpperCase()); } if (typeof value number) { // 这里value被收窄为number console.log(value.toFixed(2)); } }第二种用instanceof判断对象类型try { // 一些会抛异常的逻辑 } catch (error) { if (error instanceof Error) { console.log(error.message); } }第三种用in运算符判断对象上的属性function processObj(value: unknown) { if (typeof value object value ! null name in value) { console.log((value as { name: string }).name); } }第四种自定义类型守卫这是最灵活也最推荐的方式适合把复杂的收窄逻辑封装起来复用interface User { id: number; name: string; } function isUser(obj: unknown): obj is User { return ( typeof obj object obj ! null id in obj name in obj ); } function handleResponse(data: unknown) { if (isUser(data)) { console.log(data.name); } }obj is User这种语法叫类型谓词它是在告诉编译器如果这个函数返回true那参数obj的类型就是User。有了类型守卫你就不用在业务代码里写一堆typeof和in的判断了逻辑也会干净很多。3.3 unknown的典型应用错误捕获与接口数据unknown真正高频出现的场景有两个。第一个场景是错误捕获。JavaScript里有个规矩就是你可以throw任何东西字符串、数字、对象甚至一个undefined。所以在TypeScript 4.4及之后的版本catch子句里捕获到的错误变量默认类型已经变成了unknown前提是开了strict模式。这意味着老版本里那种error.message直接用的写法在更新依赖后会突然报错。正确做法是先收窄try { JSON.parse(input); } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log(不是标准Error对象, error); } }第二个场景是处理外部接口返回的数据。我们在真实项目里接口的返回内容其实是不可信任的哪怕后端同事拍着胸脯说你随便用也可能因为某个字段没传、某种异常分支没处理在线上的某个角落给你返回一个完全意料之外的结构。用unknown先接住再逐步收窄能让你的代码对脏数据更敏感。async function fetchUserDetail(id: string): Promiseunknown { const res await fetch(/api/user/${id}); const data: unknown await res.json(); return data; } async function renderUserName(id: string) { const data await fetchUserDetail(id); if (isUser(data)) { document.title data.name; } else { console.warn(接口返回的数据不符合预期); } }很多人不喜欢unknown觉得它麻烦。但我愿意称它为诚实的麻烦它把你推到一个必须验证数据的境地而这个验证过程恰恰是不少线上事故的防火墙。4. never不存在也是一种类型4.1 从空集理解nevernever官方定义叫做底部类型bottom type。前面说过如果any和unknown是顶级类型代表所有类型的父类型那never就是所有类型的子类型。这意味着它只能代表一种情况永远不会有任何值。两个直接推出来的规则大家一定要记住第一没有任何实际值能被赋给never。你没法把一个number赋值给never也不能把string赋值给never因为没有值是属于空集的。let n: never; n 123; // 报错 n hello; // 报错第二never可以赋值给任意类型。空集是任何集合的子集所以一个never类型的值你可以放心地把它交给string、number、甚至unknown。这个特性在后面的穷尽性检查里非常关键。那什么样的表达式才具有never类型呢最常见的是两类。一类是永远执行不完的函数要么抛出异常、要么死循环。function throwError(message: string): never { throw new Error(message); } function infiniteLoop(): never { while (true) {} }另一类是经过类型收窄后剩余的可能性完全为空。比如function handle(value: string | number) { if (typeof value string) { // 这里value是string } else if (typeof value number) { // 这里value是number } else { // 这里value被推断为never // 因为string | number 被上面的分支分完了逻辑上不可能走到这里 } }很多朋友第一次看到这里会发蒙既然永远不可能走到else那这个分支还有存在的意义吗别急它的意义比你想象中大多了。4.2 穷尽性检查让编译器替你把漏掉的分支找出来想象你在做一个图形计算器图形的类型是一个联合类型type Shape | { kind: circle; radius: number } | { kind: square; side: number }; function getArea(shape: Shape) { switch (shape.kind) { case circle: return Math.PI * shape.radius ** 2; case square: return shape.side * shape.side; default: // 这里shape应该是never const _exhaustiveCheck: never shape; return _exhaustiveCheck; } }这段代码的精妙之处在于default分支里如果shape是never那么const _exhaustiveCheck: never shape不会报错。但如果某一天有人往Shape联合类型里加了一个新成员比如矩形type Shape | { kind: circle; radius: number } | { kind: square; side: number } | { kind: rectangle; width: number; height: number };这时候因为separatorswitch没有处理rectangle分支default里的shape类型就变成了{ kind: rectangle; width: number; height: number }。而把它赋值给never就会立刻触发编译器报错。当我们看到这个红色波浪线时第一反应就是啊还有矩形没处理这个方法叫穷尽性检查Exhaustive Check。我在实际项目中是靠它抓过不少漏网的边界情况比如枚举值新增后忘记处理、后端返回新状态码但没有对应分支等等。它把人肉检查所有分支这件事交给了编译器去盯。4.3 never是高级类型编程的基石对初学者来说never在类型编程里的运用可能偏进阶但了解它会让你对整个类型系统的理解更有深度。先看一个最经典的例子NonNullableT工具类型。type NonNullableT T extends null | undefined ? never : T; type A NonNullablestring | null | undefined; // A的结果是 string为什么结果只留下了string这里用到了联合类型的一个特性条件类型在遇到联合类型时会把联合类型的每个成员分别代入判断再把结果组成新的联合类型。所以上面这个判断实际发生的过程是用string去试string extends null | undefined不成立返回string用null去试null extends null | undefined成立返回never用undefined去试undefined extends null | undefined成立返回never最后把结果联合起来string | never | never。而联合类型里never会被自动过滤掉于是最终结果就是string。这个自动过滤的特性让never成为很多内置工具类型比如ExcludeT, U、ExtractT, U的核心零件。你甚至可以用它自定义一个从联合类型里剔除指定成员的类型type MyExcludeT, U T extends U ? never : T; type B MyExcludea | b | c, a; // B的结果是 b | c能看到这里的读者类型编程的底子已经不错了。但更重要的是你理解了为什么在联合类型里never会被和谐、为什么它能用来做过滤。这些点串起来之后再回去看源码级别的类型定义就不会那么晕了。5. 一张表看懂三者的区别5.1 核心行为对比用文字讲清概念之后我用一张对比表把关键行为列出来方便你以后忘了随时翻看。对比维度anyunknownnever能否接受任意类型的赋值能能不能没有任何实际值可赋给它能否赋值给其他具体类型能不能必须先收窄能因为它可以赋值给任何类型能否直接读取属性/调用方法能完全不检查不能必须先收窄不能因为没有任何值是顶级类型还是底部类型顶级类型顶级类型底部类型对类型安全的影响完全关闭类型检查强制使用前验证帮助编译器发现逻辑漏洞典型使用场景第三方库缺类型、JS迁移过渡接口响应、catch捕获、解析数据抛出异常的函数、穷尽性检查、条件类型过滤这张表里的核心就三句话any什么都能干但什么都不管unknown什么都能接但用之前要证明never没有值但它能帮你找出代码里漏掉的情况。5.2 遇到类型怎么选我的决策原则在真实编码里每次遇到不知道用什么类型的时候我脑子里都会快速过一个决策流程第一步先想清楚这个值到底存不存在如果它不可能存在那直接上never。第二步如果它必然存在但类型目前无法确定我通常先写unknown然后再想能不能用类型守卫或者解析函数把它收窄成一个具体类型。因为unknown至少保证我不会在类型上作弊。第三步只有在我已经尝试了unknown、但发现它带来的收窄成本实在太高、或者第三方库完全没有类型、或者这是从JS迁到TS的过渡代码时我才会考虑用any并且我一般会在旁边加一行注释说明这个any为什么存在、什么时候应该替换掉。这套原则听起来朴素但真的能少掉很多坑。尤其是你养成默认不用any的习惯之后代码的可读性和可维护性都会有一个非常明显的提升。我还有一个记忆技巧用生活场景类比any像街边小店的全场随便摸没规矩但小偷也能进。unknown像机场安检所有人都得过一遍机器证明你没有问题才能走。never像一间关得严严实实的空房子里面谁也进不去但它存在本身就是一种约束。这么一想三个类型其实很有个性也很难再混了。6. 从几个开发报错看这三个类型6.1 unknown error为什么不是玄学日常开发中只要多跟接口打交道你应该没少见这种报错unexpected status 404 not found: unknown error这类报错看着跟玄学一样其实本质很简单程序收到了一个它不认识的错误状态。它知道请求失败了但具体是什么原因、错误结构长什么样程序并不清楚所以只能笼统地给你抛一个unknown error。换句话说程序手里拿到的就是一个unknown类型的数据。如果从TypeScript的类型视角去理解这个问题就能套进前面学的知识当错误信息是unknown时你千万不要直接对它做error.message之类的假定。正确做法是先用状态码、错误字段这些收窄手段去判断。比如HTTP 404配合错误码就能确定是路径不存在还是资源被移除。这跟类型收窄的哲学是一模一样的。有的框架里你还会看到unknown error: 404 not found这种带状态的输出其实这些状态码就是程序在帮你做收窄4xx代表客户端问题5xx代表服务端问题。可如果你连这个状态码都不去解析只盯着unknown error四个字看那就真成了对着玄学发呆了。6.2 does not match any让我想起了never再来看另一个经典报错error: src refspec main does not match any error: failed to push some refs这是我当年第一次用Git时经常踩的坑。它的意思是你指定的main分支在本地Git仓库里根本不存在。所以push的时候Git找不到任何提交可以对应到main这个引用。这个报错里的does not match any非常传神——它对应的恰好就是never类型的概念你引用的对象在值集合里一个都不匹配你的目标分支在仓库里是空集。从类型系统的角度看这就相当于说No value can be assigned to the type main。每次看到这类报错我都会觉得编程语言里的很多概念是相通的一个不存在的分支、一个永远不会有值的类型本质上都是在描述空集。理解了never你不仅能看懂TypeScript的类型报错还能看懂Git的引用报错背后的原因。6.3 英文命名里藏着设计意图最后聊一个特别有意思的细节any、unknown、never这三个英文词本身就在暗示它们的使用边界。any的意思是任何一个、任意一个。它强调的是一种自由选择——你想把它当什么都行。但自由另一面就是不设防。unknown的意思是未知的。它强调的是认知的边界——我知道你不知道。所以它允许你先存着但规范了使用前的验证流程。never的意思是永不。它强调的是不存在的事实——不是我不知道而是它压根就没有。所以它最适合表达永不返回值永远不会执行的分支。你看这些词的语义其实非常准确。所以下次拿不准的时候先问自己一句我面对的值是随便什么都行还是现在不知道但以后要查还是根本不可能有答案就自然出来了。7. 常见问题与避坑实录7.1 为什么catch到的error突然不能直接.message了这个问题是很多升级TypeScript版本的朋友都会遇到的。以前老版本里catch的error参数类型是any所以你可以直接error.message。但TS 4.4之后strict模式下useUnknownInCatchVariables默认开启error的类型变成了unknown。碰到这种情况我最推荐的解决方式就是收窄catch (error) { if (error instanceof Error) { sendLog(error.message); } else { sendLog(未知错误, error); } }也有人图省事直接(error as any).message这种做法我不反对但你要想清楚你丢掉的不仅是类型安全还有对异常类型的判断能力。真实环境里抛出来的未必都是Error实例可能是后端返回的自定义错误对象也可能是网络层抛出的超时对象。先做判断再取字段是更稳妥的路径。7.2 为什么函数会被推断出never返回类型有读者问过我的函数明明有逻辑为什么TypeScript把返回类型推断成了never排查思路很简单。先看函数体是不是处于路径必然中断的情况。比如函数内部没有任何return但在某个分支上直接throw了而且这个分支在控制流上必然走到或者函数是一个无限循环没有跳出条件。这两种情况TS会把返回值推断成never。还有一种情况是递归类型的问题。如果你定义一个泛型在条件类型那里不小心返回了never导致递归展开后类型变成了never这个时候就要回头检查条件类型的分支是不是写反了。比如type DeepRequireT { [K in keyof T]: T[K] extends object ? DeepRequireT[K] : T[K]; };如果你在某个分支上写了never且这个分支被意外命中整个类型就会变成never调用方那边的参数就会变成不匹配任何值的状态。这种bug比较高级遇到时先单独抽出来测一下条件类型的输入输出定位会快很多。7.3 几个让我少踩坑的小习惯最后分享几个我这几年的小习惯谈不上多高级但都对稳定产出很有帮助。习惯一把unknown理解为过渡态不要长驻类型定义里。unknown用来接外部数据可以但不要在业务模型里存放几十个unknown字段。接到unknown之后尽快用一个解析函数把它校验成具体的接口类型后面就不会处处受阻。习惯二在switch处理联合类型时永远给default分支加一个never检查。这几乎是零成本的但能帮你在后续改动时第一时间发现遗漏。习惯三别为了好看硬把any换成never。有个读者以前特别喜欢给函数标注never返回觉得这是一种高级感。但真实业务逻辑里一个会return结果的函数你要是硬标成never编译器立刻就会警告你而且将来别人调用你的函数时会莫名奇妙地发现返回值用不了。类型标注要诚实never不是装饰品它是逻辑的产物。习惯四在tsconfig里尽量打开strict和noImplicitAny。短时间看可能会多写一些类型声明但从项目长期演进的视角看这是最值得的设置。它能让你在一开始就避开大量类型隐患而不是等到项目大了再回头补坑。我个人在写TypeScript的时候其实也是经历了惧怕unknown、滥用any、忽视never这样的成长曲线。现在回过头看这三个类型最值得学的不是各自的api而是背后那种把不确定性摊开管理的思路。any是不设防的放任unknown是安全的谨慎never是逻辑的收尾。你在项目里把这个思路贯彻下去你的代码会比很多网上的demo稳得多。最后再多说一句如果你最近刚被某个unknown error搞到焦头烂额不妨先冷静下来把它当成一个unknown类型的值先收窄、再处理大概率能少走很多弯路。