TypeScript unknown类型实战:告别any,掌握类型收窄与安全编程
如果你写过一段时间 TypeScript大概率和我一样经历过这么几个阶段一开始觉得any是真香什么类型报错都是as any一把梭写到后面发现代码里的any越来越多类型保护形同虚设重构的时候心态直接爆炸再往后开始反思主动用unknown替换any才发现这才叫类型安全的正解。这篇文章就是围绕unknown这个类型把它的设计初衷、类型规则、收窄技巧、实战用法、常见坑位一次讲透希望能帮你少走一点弯路。顺便说一句很多朋友去搜 “unknown”结果被各种无关报错淹没——502 网关错误里的 “unknown error”、Git 的 “unknown switch”、Docker 的 “unknown flag”这些和 TypeScript 没有半点关系。今天咱们只聊 TypeScript 里这个关键的unknown类型。1. 先搞清楚 unknown 到底解决什么问题在写代码之前得先理解unknown为什么会出现。没有这个背景你很容易把它当成any的换皮用起来依然一脸懵。1.1 any 的“原罪”类型检查失效先聊聊any。any本质上是 TypeScript 的一个逃生舱一旦把一个值标记成any编译器就对这个值完全放弃检查。比如说let data: any; data hello; data 42; data { name: 张三 }; data.foo.bar.baz; // 编译不报错运行直接炸最后一行在编译期毫无动静因为any会跳过属性访问检查但运行时大概率给你抛一个 TypeError。这时候你才发现any的真正问题不是“不能用”而是“用了之后错误全部推迟到运行时”和写 JavaScript 没有本质区别。我见过不少项目前期为了赶进度到处any后期一重构全是Object is of type any这类没有实际信息的报错定位问题全靠猜。这不是 TypeScript 的错是any用错了地方。它把类型检查变成了摆设等于亲手把编译器这个最可靠的助手关掉了。1.2 unknown类型安全的 anyTypeScript 3.0 引入了unknown类型官方对它的定位就是“类型安全的 any 对应物”type-safe counterpart of any。什么意思就是说unknown也接受任何类型的值但它不允许你在没有收窄narrowing之前去使用这个值。可以用一个不太严谨但很好记的类比any是一箱没拆封的快递你随手就能把里面的东西掏出来用但掏出来的可能是炸弹unknown是一箱同样没拆封的快递你必须先开箱验货确认里面是啥之后才能用。这个过程在类型系统里就叫类型收窄。从这个角度看unknown并不是要取代any而是给“确实不知道是什么类型”的场景一个更安全的兜底方案。两者的本质差异我后面会详细对照。顺便补充一个容易混淆的点unknown在很多资料里被归类为“顶部类型”top type意思是在类型层级上任何类型都能赋值给它而never是“底部类型”bottom type没有任何类型能赋值给never除了never本身。这两个概念是理解 TypeScript 类型系统的一把钥匙很多高级类型体操都建立在“top type”和“bottom type”的边界推演上。1.3 什么时候你该用到 unknown从我的实践来看unknown的典型使用场景大概有这么几类接口返回的外部数据尤其是第三方 API 的响应体用户上传的内容、配置文件、动态加载的模块JSON.parse的返回值这种标准库本身定义为any的地方catch子句捕获的异常对象TS 4.0 之后默认是unknown写通用工具函数、类型体操时对泛型参数的默认约束。这些场景的共同点就是值来自不可信边界类型在编译期无法确定。这时候用any就是“放弃治疗”用unknown则是“先按未知处理验证后再放行”。后面第 4 节我会逐个展开把每个场景的代码都写出来。2. unknown 的类型规则能收不能放理解unknown的类型规则核心就一句话能收不能放。这一节我会把这话掰开揉碎讲清楚。2.1 可赋值性谁都进得来出不去unknown的第一条核心规则是可赋值性assignability所有类型都能赋给unknown但unknown反过来只能赋给unknown或any。你可以把这句话当成“能收不能放”。let value: unknown; // 所有类型的值都能赋给 unknown value hello; // OK value 42; // OK value true; // OK value { id: 1 }; // OK value [1, 2, 3]; // OK // 但 unknown 不能赋给其他具体类型 let str: string; str value; // 报错Type unknown is not assignable to type string // 只能赋给 unknown 或 any let otherUnknown: unknown; otherUnknown value; // OK let anything: any; anything value; // OK可能有人会问既然任何类型都能赋给unknown那岂不是什么都能往里塞确实如此这是它的设计使然。unknown的价值不在于“不能收”而在于“不能放”——它强迫你在使用之前必须做收窄。这个特性在写通用容器、类型安全的工具函数时特别有用因为你可以放心地把任意值存进去而不用担心某个具体类型被意外污染。2.2 使用禁区未收窄前无法操作第二条规则是unknown类型的值在收窄之前几乎什么操作都不能做。这是它与any最直观的区别。declare const data: unknown; data.toString(); // 报错Object is of type unknown data.name; // 报错Object is of type unknown data[0]; // 报错Object is of type unknown new data(); // 报错Object is of type unknown data 1; // 报错Object is of type unknown这些报错的本质是编译器认为一个unknown的值没有任何已知的成员或行为所以任何对它成员的访问、方法调用、构造操作都是不安全的。你必须先把unknown收窄成某个具体类型才能进行对应操作。这个“死板”恰恰是安全感的来源。它保证你在查清类型之前不会被“顺手”把未知值当成已知值用。我看到有同事抱怨 “unknown 太麻烦什么都不能干”这种抱怨其实说明他把问题搞反了不是unknown麻烦而是他试图对一个未知的东西做已知的操作本身就是危险的。2.3 any 与 unknown 关键对照表我把这两个类型的关键差异整理成一张表工作中拿不准的时候翻一下很方便维度anyunknown赋值给其他类型可以赋给任何类型只能赋给unknown或any属性/方法访问不报错运行时风险高报错必须收窄后使用构造函数 new允许报错类型收窄需求不需要必须收窄才能使用类型检查覆盖完全跳过强制检查适用场景极少仅临时过渡外部数据、未知输入、通用容器对代码质量的长期影响类型信息退化保持类型安全在团队协作中的体验重构处处受阻重构相对安全一句话总结any是“跳过检查”unknown是“推迟检查”。跳过检查等于放任自流推迟检查则是先确认再使用这是本质区别。2.4 unknown 在联合类型与交叉类型中的表现unknown出现在联合类型和交叉类型里时行为也很有特点很多人容易忽略。先看联合类型type A string | unknown; // 结果是 unknown type B string | number | unknown; // 还是 unknown因为unknown是 top type任何类型和它做联合都会“吸收”掉其他类型直接变成unknown。这背后的逻辑是一个值只要是unknown它可能是 A 也可能是 B联合类型的可能性被unknown完全覆盖了。再看交叉类型type C string unknown; // 结果是 string type D { a: number } unknown; // 结果是 { a: number }交叉类型则相反unknown会被“吸收”掉。因为交叉类型要求值同时满足所有成员而unknown对所有类型都放行所以剩下的约束就是另一个类型本身的约束。理解了这两个行为你在设计类型别名时就不会被A unknown这种写法搞晕。3. 类型收窄把 unknown 真正用起来的核心技术如果说前两节是理论这一节就是实操。unknown存在的意义最终都要靠类型收窄来实现常见的收窄手段有typeof、instanceof、in、自定义类型守卫、判别联合等。我会一个个讲再给一个综合示例。3.1 typeof 收窄与 null 陷阱typeof是最直观的收窄方式适用于原始类型primitive。function processUnknown(data: unknown): void { if (typeof data string) { // 这里 data 被收窄为 string console.log(data.toUpperCase()); } else if (typeof data number) { // 这里 data 被收窄为 number console.log(data.toFixed(2)); } else if (typeof data object data ! null) { // typeof null object所以必须排除 null console.log(对象类型但具体是啥还得继续查); } }这里有一个新手必踩的坑typeof null返回的是object。所以当你用typeof data object做判断时必须同时检查data ! null否则null也会溜进对象分支后面访问属性又是一堆报错。这个坑在搜索引擎里几乎天天有人问很多人就是卡在这一步。另外typeof能识别的类型只有string、number、boolean、symbol、bigint、object、function、undefined这几种对数组、Date、Map 这类复杂类型无能为力。如果想区分这些得用instanceof或Array.isArray。3.2 instanceof 与 in处理对象和类实例typeof处理不了对象内部结构就得靠instanceof和in。class User { constructor(public name: string, public level: number) {} } function describe(data: unknown): string { if (data instanceof User) { // 收窄为 User return 用户 ${data.name}等级 ${data.level}; } if (typeof data object data ! null title in data) { // 收窄为 { title: unknown } 之类 return 标题${data.title}; } return 无法识别的值; }注意in操作符要求判断的左边是string | number | symbol右边是对象。如果你拿unknown直接做in判断编译器会先报错所以我通常先做一次typeof data object data ! null的判断把data收窄成object再交给in。instanceof适合判断类实例比如 Date、Array虽然 Array 一般用Array.isArray更稳、自定义类、或者来自其他模块的构造函数的实例。它的原理是基于原型链的运行时检查所以对于跨 iframe、跨 realm 的对象可能会失效这是instanceof本身的限制不是你写错了。3.3 自定义类型守卫业务收窄的钥匙实际业务里要收窄的对象往往不是内置类型而是接口返回的各种业务结构。这时候自定义类型守卫type predicate是最顺手的工具。interface ApiResponseT { code: number; message: string; data: T; } function isApiResponse(value: unknown): value is ApiResponseunknown { if (typeof value ! object || value null) { return false; } const obj value as Recordstring, unknown; return ( typeof obj.code number typeof obj.message string data in obj ); } function handleResponse(raw: unknown) { if (isApiResponse(raw)) { // 这里 raw 被收窄为 ApiResponseunknown console.log(raw.code, raw.message, raw.data); } }自定义类型守卫的写法就是函数返回值类型写成value is SomeType函数内部手动判断是否符合类型结构返回boolean。只要返回true编译器就信任你的判断把这个值收窄成目标类型。这里有个容易忽略的细节类型守卫的职责是“判断这个值是不是这个类型”而不是“判断这个值是否包含某个属性”。如果守卫写得太粗糙比如只判断了code和message没判断data那收窄后的raw.data还是unknown后续使用依然要收窄。所以守卫的粒度要根据使用场景来定。如果你希望收窄后能直接使用data就把data的检查也写进去。还有一个实战心得类型守卫内部的断言value as Recordstring, unknown是合理的因为在这里你是在“信任自己写的运行时检查”而不是盲目跳过检查。把断言集中封装在守卫里远比散落在业务代码里安全。3.4 判别联合与 switch 收窄当外部数据可能是多种形态之一时判别联合discriminated union是非常优雅的收窄方案。核心思想是用一个固定的字段比如type、kind、status来区分不同的结构。type EventData | { type: click; x: number; y: number } | { type: input; value: string } | { type: scroll; target: HTMLElement }; function isEventData(value: unknown): value is EventData { if (typeof value ! object || value null) return false; const obj value as Recordstring, unknown; return obj.type click || obj.type input || obj.type scroll; } function handleEvent(raw: unknown): void { if (!isEventData(raw)) { return; } switch (raw.type) { case click: // 收窄为 { type: click; x: number; y: number } console.log(raw.x, raw.y); break; case input: // 收窄为 { type: input; value: string } console.log(raw.value.trim()); break; case scroll: // 收窄为 { type: scroll; target: HTMLElement } raw.target.scrollIntoView(); break; } }判别联合最大的好处是switch到哪一个分支编译器就把raw收窄成对应的那个成员类型成员里的字段全都可用不用反复判断。前提是isEventData这个守卫必须正确识别出type字段否则收窄也会失真。我建议在实际项目里给每种外部事件/消息都定义一个isXxx守卫这样不仅类型安全还能当一份“数据字典”用。新同事接手代码时看守卫就能知道这个事件有哪几种形态。3.5 综合示例一个安全的格式化函数把上面的手段组合起来写一个能处理多种输入并安全输出的函数function formatValue(value: unknown): string { if (value null) { return null; } switch (typeof value) { case string: return value.trim(); case number: return Number.isFinite(value) ? value.toFixed(2) : String(value); case boolean: return value ? 是 : 否; case bigint: return value.toString(); case symbol: return value.description ?? ; case function: return value.name || anonymous function; case object: if (Array.isArray(value)) { return value.map((item) formatValue(item)).join(, ); } if (value instanceof Date) { return value.toISOString(); } return JSON.stringify(value); case undefined: default: return 未知类型; } }这个函数看起来简单其实把typeof、Array.isArray、instanceof、JSON.stringify的组合都用上了。日常写工具函数时这种“先逐层收窄最后兜底”的模式非常实用也让整个函数的类型安全性有了保障。你可以把它当成一个模板遇到需要处理不确定输入的场景照着这个结构写就不会乱。4. unknown 在真实业务场景中的应用讲完收窄技术下面看几个我实际项目中经常遇到的场景。这些场景是unknown真正发光发热的地方。4.1 解析外部 API 响应前端调后端接口返回的数据永远是最值得警惕的。很多人习惯直接const data await response.json()然后当类型用。response.json()的签名返回Promiseany如果后端哪天改了字段结构前端在编译阶段不会有任何提示运行时就崩了。更稳妥的做法是把返回值先声明成unknown再做结构校验interface User { name: string; email: string; } function isUser(value: unknown): value is User { if (typeof value ! object || value null) return false; const obj value as Recordstring, unknown; return typeof obj.name string typeof obj.email string; } async function fetchUser(): PromiseUser { const response await fetch(/api/user); const raw: unknown await response.json(); if (!isUser(raw)) { throw new Error(接口返回结构异常); } // 收窄完成后raw 才是安全的 User return raw; }这里的isUser是一个自定义类型守卫逐字段校验name和email。有人觉得这样写啰嗦多写几个校验函数而已。但好处非常直接接口结构一旦变化第一个报错的地方是校验函数而不是线上某个用户的操作。把错误拦截在边界上永远好过让错误在业务逻辑深处炸开。4.2 处理 JSON.parse 与 localStorageJSON.parse是一个历史遗留问题。它的签名是parse(text: string): any所以无论你怎么谨慎拿到的值都是any所有类型信息在parse的一瞬间就丢了。一个常见的解决方案是包一层safeJsonParsefunction safeJsonParse(text: string): unknown { return JSON.parse(text); } const raw safeJsonParse(localStorage.getItem(userConfig) ?? {}); // raw 是 unknown必须收窄后才能用这层包装看似简单作用却很大它把any的出口堵上了。队友后续看到raw的类型是unknown就会自然而然地做校验收窄而不是像以前那样直接raw.name。更进一步配合上一小节的类型守卫可以做成通用的“解析校验”组合function parseAndValidateT( text: string, guard: (value: unknown) value is T ): T | null { try { const raw: unknown JSON.parse(text); if (guard(raw)) { return raw; } return null; } catch { return null; } }这种模式在读取 localStorage、解析配置文件、处理 WebSocket 消息时都非常好用强烈推荐把它纳入团队的基础工具库。用的时候可以这么写const config parseAndValidate(localStorage.getItem(config) ?? {}, isConfig); if (config) { console.log(config.host); }4.3 catch 子句与错误收窄TypeScript 4.0 之后catch子句的变量默认类型是unknown。这是一个很多人没注意到的变化。在 4.0 之前catch里拿到的error是any你随便访问error.message都不报错4.0 之后直接访问就报错Object is of type unknown。正确的做法是先收窄try { // 某段可能抛错的代码 } catch (error) { // error 的类型是 unknown if (error instanceof Error) { console.error(error.message); return; } if (typeof error string) { console.error(error); return; } console.error(未知异常, error); }这里有个值得说道的点在 JavaScript 里throw出来的东西不一定是Error实例可能是任意值。字符串、数字、普通对象都能被throw。所以catch里的收窄不是走形式而是真实必要的。我还见过一个很典型的写法误区把error直接转成Error再访问message也就是const message (error as Error).message。如果throw出来的本来就是个字符串这里取到的就是undefined排查问题的时候反而更糊涂。建议先做instanceof判断再考虑访问成员。4.4 泛型默认约束与类型操作泛型的默认约束很多人没注意function fooT(x: T): T这里的T实际上默认约束就是unknown。你可以反过来利用这一点写一些更安全的泛型工具。// 等价于 T extends unknown function identityT(value: T): T { return value; } // 显式声明 unknown 约束方便后续收窄 function safelyGetLengthT extends unknown(value: T): number { if (typeof value string || Array.isArray(value)) { return value.length; } return 0; }在条件类型里unknown也是一个重要的兜底分支。比如type IsUnknownT unknown extends T ? (T extends unknown ? (T extends any ? true : false) : never) : false;这个类型体操不用细究但可以说明一点unknown在类型层面的判断逻辑非常严谨它是检测“未知类型”的基础工具。做高级类型设计时unknown和never经常搭配出现用来圈定类型的边界。条件类型T extends unknown ? X : Y实际上恒为X这是一种常用的“遍历联合类型”技巧的基础。4.5 WebSocket 消息与运行时校验库配合WebSocket 消息和postMessage是另一个典型的不可信输入场景。消息内容通常是字符串需要 parse 之后才能用而且格式随时可能被服务端调整。type WsMessage | { kind: join; roomId: string } | { kind: chat; text: string; from: string } | { kind: leave; roomId: string }; function isWsMessage(value: unknown): value is WsMessage { if (typeof value ! object || value null) return false; const obj value as Recordstring, unknown; if (typeof obj.kind ! string) return false; switch (obj.kind) { case join: case leave: return typeof obj.roomId string; case chat: return typeof obj.text string typeof obj.from string; default: return false; } } ws.onmessage (event) { const raw: unknown JSON.parse(event.data); if (isWsMessage(raw)) { // 处理消息 } };如果你的团队已经引入了 zod、yup 这类运行时校验库也可以把unknown和它们结合先把数据声明成unknown再交给 zod schema 做解析。zod 内部本身就是一个强大的类型守卫schema.parse(raw)的返回值类型会被推断成具体的 schema 类型使用起来比手写守卫更省事。import { z } from zod; const UserSchema z.object({ name: z.string(), email: z.string().email(), }); const raw: unknown await response.json(); const result UserSchema.safeParse(raw); if (result.success) { // result.data 已经是 { name: string; email: string } console.log(result.data.name); } else { // result.error 里有详细的校验失败信息 console.error(result.error.issues); }这里的关键是不要让 zod 的输入参数变成any而是保持unknown。zod 的多数 API 都能接受unknown输入这样既能享受校验库的便利又不丢掉“先校验后使用”的类型安全原则。5. 常见坑位与排查技巧这一节我把自己踩过的、以及帮同事排查过的问题整理一下。很多报错信息在搜索引擎里频繁出现但真正实用的解决思路往往散落在各种讨论帖里。5.1 “Object is of type unknown”到底怎么解这是使用unknown最常见的报错。看到它的第一反应不应该是“把这个类型改成any”而是问自己这个值我收窄了吗排查顺序我一般是这样检查当前作用域内有没有做类型收窄判断typeof、instanceof、类型守卫等如果做了检查收窄是否覆盖了当前使用分支如果没有做想想这个值的来源它可能是什么类型用哪种收窄方式最合适最后实在没法收窄才考虑用类型断言但要在旁边注释原因。实际操作中80% 的情况是第 3 步没做好——没有先判断就使用编译器当然不会放行。很多朋友一看到这个报错就联想到 “unknown error” 之类的系统错误其实冷静下来它只是一个“你还没收窄就开始用”的提示。5.2 类型断言要克制收窄优先断言兜底很多人嫌收窄麻烦直接(value as User).name。这确实能过编译但并不是类型安全地使用unknown而是一脚把类型检查踹开了。我一直坚持一个原则收窄优先断言兜底。如果一定要用断言至少保证断言的对象确实具备某个基础特征。// 非常危险完全跳过检查 const name (value as User).name; // 稍微好一点先做了基本判断再断言 if (typeof value object value ! null) { const name (value as User).name; }但更推荐的做法是把断言封装在自定义类型守卫里。守卫内部可以随意断言因为那是你集中控制风险的地方外面的业务代码则保持着干净的收窄逻辑。这个习惯能大幅减少as散落各处带来的维护风险。5.3 滥用 unknown 也可能让代码变啰嗦凡事都有个度。unknown不是万金油不该用它的地方别硬用。我见过一个极端案例有人把项目里所有接口返回都改成unknown然后每个页面都写一套校验逻辑代码量瞬间膨胀还容易出现守卫写错导致的隐性 bug。什么时候不该用unknown当类型来源是可信的、明确的且你已经通过其他方式比如 zod、yup 等运行时校验库完成过校验时就不要再层层unknown。校验过的数据直接声明具体类型减少无谓的收窄代码。一个实用的取舍标准外部边界API 响应、localStorage、WebSocket、用户输入用unknown收窄后使用内部流转函数参数、模块间传递声明具体类型第三方库没有类型定义时先包一层适配器把any堵在边界外而不是让any流进业务代码。5.4 常见报错速查表我把高频报错整理成一张表排查时对着看就行报错信息原因解决办法Object is of type unknown未收窄就使用unknown先typeof/instanceof/ 守卫收窄Type unknown is not assignable to type xxx把unknown赋给具体类型先收窄或使用断言不推荐Type unknown is not assignable to type string同上常见于变量赋值与函数实参收窄后再传递value is possibly unknown条件分支未覆盖所有路径检查所有分支补兜底 elseOperator cannot be applied to types unknown and number对unknown做运算先收窄成number再运算Argument of type unknown is not assignable to parameter of type xxx把unknown传给函数参数收窄后再传或用守卫校验这些报错本质都是unknown的“能收不能放”规则在起作用。把规则记牢报错原因基本一眼就能定位。5.5 配合 ESLint 与 strict 模式做团队约束个人习惯靠自律团队规范要靠工具。如果你们团队经常有人把any和unknown混用建议在 ESLint 配置里加上几条规则{ rules: { typescript-eslint/no-explicit-any: warn, typescript-eslint/no-unsafe-assignment: error, typescript-eslint/no-unsafe-member-access: error, typescript-eslint/no-unsafe-call: error } }这些规则会强制团队成员在遇到未知类型时使用unknown而不是 any