如果你写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): Promise<unknown> { 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 从空集理解never
never官方定义叫做"底部类型"(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在类型编程里的运用可能偏进阶,但了解它会让你对整个类型系统的理解更有深度。
先看一个最经典的例子:NonNullable<T>工具类型。
type NonNullable<T> = T extends null | undefined ? never : T; type A = NonNullable<string | 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成为很多内置工具类型(比如Exclude<T, U>、Extract<T, U>)的核心零件。你甚至可以用它自定义一个从联合类型里剔除指定成员的类型:
type MyExclude<T, U> = T extends U ? never : T; type B = MyExclude<"a" | "b" | "c", "a">; // B的结果是 "b" | "c"能看到这里的读者,类型编程的底子已经不错了。但更重要的是:你理解了为什么在联合类型里never会被和谐、为什么它能用来做过滤。这些点串起来之后,再回去看源码级别的类型定义,就不会那么晕了。
5. 一张表看懂三者的区别
5.1 核心行为对比
用文字讲清概念之后,我用一张对比表把关键行为列出来,方便你以后忘了随时翻看。
| 对比维度 | any | unknown | never |
|---|---|---|---|
| 能否接受任意类型的赋值 | 能 | 能 | 不能,没有任何实际值可赋给它 |
| 能否赋值给其他具体类型 | 能 | 不能,必须先收窄 | 能,因为它可以赋值给任何类型 |
| 能否直接读取属性/调用方法 | 能,完全不检查 | 不能,必须先收窄 | 不能,因为没有任何值 |
| 是顶级类型还是底部类型 | 顶级类型 | 顶级类型 | 底部类型 |
| 对类型安全的影响 | 完全关闭类型检查 | 强制使用前验证 | 帮助编译器发现逻辑漏洞 |
| 典型使用场景 | 第三方库缺类型、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 DeepRequire<T> = { [K in keyof T]: T[K] extends object ? DeepRequire<T[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类型的值:先收窄、再处理,大概率能少走很多弯路。