TypeScript unknown类型实战:告别any,掌握类型收窄与安全编程
2026/9/10 16:57:58 网站建设 项目流程

如果你写过一段时间 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 的“原罪”:类型检查失效

先聊聊anyany本质上是 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:类型安全的 any

TypeScript 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反过来只能赋给unknownany。你可以把这句话当成“能收不能放”。

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
赋值给其他类型可以赋给任何类型只能赋给unknownany
属性/方法访问不报错,运行时风险高报错,必须收窄后使用
构造函数 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存在的意义最终都要靠类型收窄来实现,常见的收窄手段有typeofinstanceofin、自定义类型守卫、判别联合等。我会一个个讲,再给一个综合示例。

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能识别的类型只有stringnumberbooleansymbolbigintobjectfunctionundefined这几种,对数组、Date、Map 这类复杂类型无能为力。如果想区分这些,得用instanceofArray.isArray

3.2 instanceof 与 in:处理对象和类实例

typeof处理不了对象内部结构,就得靠instanceofin

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 ApiResponse<T> { code: number; message: string; data: T; } function isApiResponse(value: unknown): value is ApiResponse<unknown> { if (typeof value !== "object" || value === null) { return false; } const obj = value as Record<string, unknown>; return ( typeof obj.code === "number" && typeof obj.message === "string" && "data" in obj ); } function handleResponse(raw: unknown) { if (isApiResponse(raw)) { // 这里 raw 被收窄为 ApiResponse<unknown> console.log(raw.code, raw.message, raw.data); } }

自定义类型守卫的写法就是:函数返回值类型写成value is SomeType,函数内部手动判断是否符合类型结构,返回boolean。只要返回true,编译器就信任你的判断,把这个值收窄成目标类型。

这里有个容易忽略的细节:类型守卫的职责是“判断这个值是不是这个类型”,而不是“判断这个值是否包含某个属性”。如果守卫写得太粗糙,比如只判断了codemessage,没判断data,那收窄后的raw.data还是unknown,后续使用依然要收窄。所以守卫的粒度要根据使用场景来定。如果你希望收窄后能直接使用data,就把data的检查也写进去。

还有一个实战心得:类型守卫内部的断言value as Record<string, unknown>是合理的,因为在这里你是在“信任自己写的运行时检查”,而不是盲目跳过检查。把断言集中封装在守卫里,远比散落在业务代码里安全。

3.4 判别联合与 switch 收窄

当外部数据可能是多种形态之一时,判别联合(discriminated union)是非常优雅的收窄方案。核心思想是:用一个固定的字段(比如typekindstatus)来区分不同的结构。

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 Record<string, 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 "未知类型"; } }

这个函数看起来简单,其实把typeofArray.isArrayinstanceofJSON.stringify的组合都用上了。日常写工具函数时,这种“先逐层收窄,最后兜底”的模式非常实用,也让整个函数的类型安全性有了保障。你可以把它当成一个模板,遇到需要处理不确定输入的场景,照着这个结构写就不会乱。

4. unknown 在真实业务场景中的应用

讲完收窄技术,下面看几个我实际项目中经常遇到的场景。这些场景是unknown真正发光发热的地方。

4.1 解析外部 API 响应

前端调后端接口,返回的数据永远是最值得警惕的。很多人习惯直接const data = await response.json(),然后当类型用。response.json()的签名返回Promise<any>,如果后端哪天改了字段结构,前端在编译阶段不会有任何提示,运行时就崩了。

更稳妥的做法是把返回值先声明成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 Record<string, unknown>; return typeof obj.name === "string" && typeof obj.email === "string"; } async function fetchUser(): Promise<User> { const response = await fetch("/api/user"); const raw: unknown = await response.json(); if (!isUser(raw)) { throw new Error("接口返回结构异常"); } // 收窄完成后,raw 才是安全的 User return raw; }

这里的isUser是一个自定义类型守卫,逐字段校验nameemail。有人觉得这样写啰嗦,多写几个校验函数而已。但好处非常直接:接口结构一旦变化,第一个报错的地方是校验函数,而不是线上某个用户的操作。把错误拦截在边界上,永远好过让错误在业务逻辑深处炸开。

4.2 处理 JSON.parse 与 localStorage

JSON.parse是一个历史遗留问题。它的签名是parse(text: string): any,所以无论你怎么谨慎,拿到的值都是any,所有类型信息在parse的一瞬间就丢了。

一个常见的解决方案是包一层safeJsonParse

function safeJsonParse(text: string): unknown { return JSON.parse(text); } const raw = safeJsonParse(localStorage.getItem("userConfig") ?? "{}"); // raw 是 unknown,必须收窄后才能用

这层包装看似简单,作用却很大:它把any的出口堵上了。队友后续看到raw的类型是unknown,就会自然而然地做校验收窄,而不是像以前那样直接raw.name

更进一步,配合上一小节的类型守卫,可以做成通用的“解析+校验”组合:

function parseAndValidate<T>( 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里拿到的errorany,你随便访问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 foo<T>(x: T): T这里的T实际上默认约束就是unknown。你可以反过来利用这一点,写一些更安全的泛型工具。

// 等价于 <T extends unknown> function identity<T>(value: T): T { return value; } // 显式声明 unknown 约束,方便后续收窄 function safelyGetLength<T extends unknown>(value: T): number { if (typeof value === "string" || Array.isArray(value)) { return value.length; } return 0; }

在条件类型里,unknown也是一个重要的兜底分支。比如:

type IsUnknown<T> = unknown extends T ? (T extends unknown ? (T extends any ? true : false) : never) : false;

这个类型体操不用细究,但可以说明一点:unknown在类型层面的判断逻辑非常严谨,它是检测“未知类型”的基础工具。做高级类型设计时,unknownnever经常搭配出现,用来圈定类型的边界。条件类型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 Record<string, 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”,而是问自己:这个值我收窄了吗?

排查顺序我一般是这样:

  1. 检查当前作用域内有没有做类型收窄判断(typeofinstanceof、类型守卫等);
  2. 如果做了,检查收窄是否覆盖了当前使用分支;
  3. 如果没有做,想想这个值的来源,它可能是什么类型,用哪种收窄方式最合适;
  4. 最后实在没法收窄,才考虑用类型断言,但要在旁边注释原因。

实际操作中,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'未收窄就使用unknowntypeof/instanceof/ 守卫收窄
Type 'unknown' is not assignable to type 'xxx'unknown赋给具体类型先收窄,或使用断言(不推荐)
Type 'unknown' is not assignable to type 'string'同上,常见于变量赋值与函数实参收窄后再传递
'value' is possibly 'unknown'条件分支未覆盖所有路径检查所有分支,补兜底 else
Operator '+' 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 模式做团队约束

个人习惯靠自律,团队规范要靠工具。如果你们团队经常有人把anyunknown混用,建议在 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询