☰
告别any:TypeScript中unknown类型收窄与安全迁移指南
2026/10/13 18:43:55 网站建设 项目流程

接手过老项目的人应该都有这种体验:打开一个标注着any的方法,想搞清楚它到底接收什么参数、返回什么结构,结果顺着调用链追了好几层,发现到处都是any,最后只能靠console.log现场打印来猜类型。TypeScript 的顶层类型(top type)里有两个特殊角色——any和unknown,它们经常被放在一起比较,但行为逻辑其实完全不同。any是类型检查的"逃逸舱",放开一切限制;unknown则是 TS 3.0 引入的"类型安全版 any",要求你必须先收窄(narrow)再使用。这篇博文想跟你聊透这两个类型的本质区别、适用场景和迁移改造经验,适合正打算从any泥潭里爬出来、或者刚接触类型收窄机制的朋友。

1. 为什么这两个类型总被放在一起讨论

先明确一个基本事实:在 TypeScript 的类型体系里,any和unknown都属于"顶层类型",意思是一切类型的值都可以赋给它们。这导致很多初学者以为"那不就是差不多嘛",但真正拉开差距的是反向赋值——any可以赋值给任意类型,unknown却不行。这一条规则,几乎决定了后续所有行为差异。

1.1 从赋值规则看第一层差异

直接上代码,感受最直观的差异:

let anything: any = "hello"; let unknownValue: unknown = "hello"; const num1: number = anything; // 编译通过,any 可以赋给任何类型 const num2: number = unknownValue; // 编译报错:Type 'unknown' is not assignable to type 'number'

unknownValue明明是一个字符串,但把它赋给number会直接报错,哪怕赋给string也一样报错。道理很简单:unknown是"未知类型",编译器不知道它具体是什么,所以不允许你在没有检查的情况下把它当作特定类型使用。而any相当于主动关闭了类型检查,赋值随便来,调用随便来,编译器根本不管。

我见过不少从 JavaScript 转到 TypeScript 的同学,习惯把接口返回值统统标成any,理由就是"反正我也不知道后端会返回什么"。问题在于,any会让错误被无限延迟——类型层面的保护完全失效,运行时炸了才反应过来。unknown存在的意义,就是把"我不知道它是什么"这件事诚实地告诉编译器,同时逼着你补上该有的校验逻辑。

1.2 any 到底"灵活"在哪里

any的灵活性体现在它几乎绕过了 TypeScript 的全部静态检查能力:

  • 可以访问任意属性,不管属性存不存在;
  • 可以调用任意方法,哪怕参数类型完全对不上;
  • 可以赋值给任意类型,也可以从任意类型接收值;
  • 可以被当作函数调用,可以在表达式里随意参与运算。
let anything: any = { foo: 1 }; anything.bar.baz; // 不报错,运行时报 TypeError anything("hello"); // 不报错,运行时报 TypeError anything + 42; // 不报错,结果可能是 "[object Object]42"

unknown则刚好相反,它像一个"加锁的箱子":能确认里面有东西,但拿不出来用。对一个unknown类型的变量,你没法直接访问属性、没法直接调用方法、没法直接参与运算,唯一能做的,就是通过类型收窄(narrowing)来逐步确认它的真实形态。

这两种设计思路,本质上是"灵活性优先"和"安全性优先"的取舍。any适合在迁移老代码时作为临时过渡,unknown适合在接入外部数据时守住类型边界。把两者的差异搞清楚,能避免很多"编译通过了,上线就挂"的诡异问题。

2. unknown 的操作边界:类型收窄是唯一通路

接触unknown之后,最常见的困惑就是"那我到底该怎么用它?"。答案只有一句话:先收窄,再使用。类型收窄是指通过条件判断、内置函数等手段,让 TypeScript 在特定代码分支里把一个宽泛类型缩小为更具体的类型。这个过程不是形式主义的,它要求在运行时做真实校验,所以哪怕数据源头不可控,后续代码也能安全运行。

2.1 typeof 与基础类型收窄

处理基本类型(string、number、boolean、symbol、bigint、undefined、function)时,typeof是最常用的收窄手段:

function processValue(value: unknown): string { if (typeof value === "string") { return value.toUpperCase(); } if (typeof value === "number") { return value.toFixed(2); } if (typeof value === "boolean") { return value ? "yes" : "no"; } return "unsupported"; }

在这个例子里,每个if分支内部的value都被收窄成了具体的类型,因此可以安全地调用toUpperCase、toFixed等方法。TS 的收窄是"沿着代码分支流动"的,一旦出了这个分支,value又变回unknown。

这里有一个老生常谈的坑:typeof null返回的是"object",而不是"null"。所以当你校验 object 类型时,必须额外排除null:

function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; }

不排除null的话,null也会被认为是对象,后面访问属性时就会炸出经典的Cannot read properties of null。

2.2 instanceof、数组判断与自定义类型守卫

处理对象和数组时,typeof就不够用了,需要组合使用instanceof、Array.isArray和自定义类型守卫。

class User { constructor(public id: number, public name: string) {} } function parseUser(value: unknown): User { if (value instanceof User) { return value; } throw new Error("不是 User 实例"); }

数组的判断在新版 TypeScript 里已经做得比较好,Array.isArray可以直接把类型收窄成任意元素的数组:

function getFirstElement(value: unknown): unknown { if (Array.isArray(value)) { return value[0]; } return undefined; }

真正的重头戏是自定义类型守卫(type guard)。当数据来自接口、文件、存储等外部环境时,运行时校验往往是唯一可靠的防线。类型守卫的写法是用值 is 类型的返回值标注,告诉 TypeScript"这个函数返回 true 时,参数就是这个类型":

interface ApiUser { id: number; name: string; email?: string; } function isApiUser(value: unknown): value is ApiUser { if (typeof value !== "object" || value === null) return false; const obj = value as Record<string, unknown>; return typeof obj.id === "number" && typeof obj.name === "string"; }

这样在调用方那里,只要通过了isApiUser的检查,TS 就会把变量当作ApiUser来对待:

function handleApiData(data: unknown): void { if (isApiUser(data)) { console.log(data.name); // 安全访问,类型已经收窄 } }

我个人的经验是:对于外部数据,类型守卫应该跟接口定义放在同一个模块里,接口负责描述"理想中的数据结构",类型守卫负责验证"真实的数据是否符合这个结构"。两者配合,才算把运行时校验和编译期类型打通了。

3. 真实项目里的高频场景对比

理论说再多都不如看实际场景。下面这几个场景是我在代码里经常遇到的,每个都对比一下any和unknown的表现,你会更清楚什么时候该选谁。

3.1 函数参数与返回值:unknown 强制调用方校验

先看一个最典型的争议点——通用解析函数。比如封装一个安全的 JSON 解析器:

function safeParse(json: string): any { try { return JSON.parse(json); } catch { return null; } } // 调用方可以毫无障碍地使用 const data = safeParse('{"name": "abc"}'); console.log(data.name); // 编译通过

用any作为返回值,调用方确实很方便,但隐患极大:如果safeParse返回的其实不是预期的结构,比如后端改了字段名、或者解析出来是数组,data.name就会在运行时静默失败。到了生产环境,错误日志只会留一句冷冰冰的TypeError,你根本不知道是哪个环节的数据出了问题。

换成unknown之后:

function safeParse(json: string): unknown { try { return JSON.parse(json); } catch { return null; } } // 调用方必须做类型收窄或断言 const data = safeParse('{"name": "abc"}'); if (typeof data === "object" && data !== null && "name" in data) { console.log(data.name); // 类型已收窄,可以访问 }

乍看之下unknown很麻烦,但这份"麻烦"是故意设计的:类型系统在提醒你,当前这个数据来源不可信,需要确认完再动手。尤其是多个模块共享同一个解析函数时,unknown能保证每个调用方都认真对待数据格式,而不是甩锅给下一个接手的同事。

3.2 联合类型与条件类型中的表现差异

在联合类型和条件类型里,any和unknown的行为也有细微差别。

先看联合类型:

type A1 = any | string; // any,顶层类型会"吸收"其他类型 type A2 = unknown | string; // unknown

这两个结果乍看一样(都是顶层类型),但语义完全不同。any | string最终收敛成any,意味着这个类型彻底失去检查能力;unknown | string收敛成unknown,至少还保留"必须收窄才能使用"的约束。

更值得留意的是交叉类型:

type B1 = any & string; // any type B2 = unknown & string; // string type B3 = unknown & number; // number

unknown和某个具体类型做交叉,结果就是那个具体类型;any做交叉则依然是any。这个特性在泛型设计里很有用。比如写一个把值约束为对象的工具类型:

type EnsureObject<T> = T & Record<string, unknown>;

如果传入T = string,结果会是string & Record<string, unknown>,看起来怪但语法上合法;如果传入T = any,结果就直接是any,类型保护完全失效。所以在泛型工具类型的实现里,我通常会用unknown而不是any来构建约束,避免类型在不知不觉中"塌房"。

3.3 API 响应解析:从 any 到 unknown 的实战改造

后端接口的数据是最典型的不可信来源。早期代码里最常见的是这种写法:

// 改造前:any 满天飞 async function fetchUser(): Promise<any> { const res = await fetch("/api/user"); return res.json(); } async function showUserName() { const user = await fetchUser(); console.log(user.profile.name); // 如果结构不对,直接炸 }

这次改造的核心,是把"返回类型"从any改成unknown,同时在数据进口处做一次完整校验:

interface UserProfile { name: string; age: number; } function isUserProfile(value: unknown): value is UserProfile { if (typeof value !== "object" || value === null) return false; const obj = value as Record<string, unknown>; return typeof obj.name === "string" && typeof obj.age === "number"; } async function fetchUser(): Promise<unknown> { const res = await fetch("/api/user"); return res.json(); } async function showUserName(): Promise<void> { const user = await fetchUser(); if (isUserProfile(user)) { console.log(user.name); } else { // 在这里做错误处理,而不是等运行时崩溃 throw new Error("用户数据格式异常"); } }

你可能会觉得"这只是把错误提前抛出来了,没本质区别"。区别其实很大:至少现在错误发生时,你能准确知道是"数据格式校验失败",而不是"访问了 undefined 的属性"。而且类型守卫的校验逻辑可以复用——同一个isUserProfile可以放在服务端渲染、客户端缓存、消息队列消费等多个入口,确保所有地方对数据格式的理解是一致的。

4. 常见问题与排查技巧实录

从any切到unknown的过程中,有几个问题几乎每个团队都会遇到。这里整理一份问题实录,都是实际踩过坑才记下来的。

4.1 "为什么 unknown 不能直接调用方法?"

这是最常见的第一个疑问。原因在前面已经说过:unknown是"未知类型",编译器不允许假设它有某个方法。破解方法就是收窄。如果确认这个unknown在运行时一定是一个字符串,可以这样写:

function formatValue(value: unknown): string { if (typeof value !== "string") { return String(value); } return value.trim(); }

有时候你实在懒得写守卫,又想快速拿到内部值,可能会想用as断言:

const str = value as string; // 我能保证它一定是字符串

但要警惕:as断言只是"骗过编译器",并不会在运行时做任何检查。如果这个值来自外部输入,断言错了,运行时照样炸。我的原则是:as断言可以用,但必须在断言之前已经有百分之百把握,并且最好加一行注释说明依据是什么。

4.2 类型收窄失效的典型场景

类型收窄也有失效的时候,最常见的坑是"把值取出来再判断"。

function process(data: unknown): void { const value = data; // 取出来之后收窄就丢失了 if (typeof data === "string") { // 这里的 data 是 string console.log(data.toUpperCase()); // 但 value 依然是 unknown console.log(value.toUpperCase()); // 报错 } }

TS 的收窄是"基于变量本身"的,你复制到别的地方并不会带着收窄结果走。解决办法是直接在原来的变量上收窄,收窄完的分支里再赋值给新变量。

另一个容易踩的坑是对象属性收窄:

function handleUser(value: unknown) { if (typeof value === "object" && value !== null) { // 此时 value 被收窄成 object // 但 object 类型上并没有 name 属性 if ("name" in value) { const name = value.name; // 正确:通过 in 收窄后访问 } } }

只判断"是对象"不能让 TS 知道有哪些属性,需要配合in操作符做进一步收窄。新版 TS 对in的支持已经很完善,"name" in value之后,value.name的类型就能正确推出来。

4.3 从 any 到 unknown 的迁移清单

如果你打算把一个存量项目从any逐步迁到unknown,建议按这个顺序操作:

  1. 先用 ESLint 把新增代码的any禁掉,确保不产生新的"逃逸舱";
  2. 从"数据进口"开始改:解析接口响应、读取 localStorage、处理 WebSocket 消息的地方,优先改成unknown;
  3. 为每种外部数据结构编写类型守卫,跟接口定义放一起;
  4. 内部模块之间的函数签名,能不用any就不用,改为明确的接口类型或泛型;
  5. 最后清理as断言,能用类型守卫就用守卫。

迁移过程中要特别注意:unknown配as断言会稀释掉一半的安全收益。常见的一种糟糕写法是:

const data: unknown = JSON.parse(json); const user = data as User; // 这里没有任何运行时校验

字面上看从any改成了unknown,实际上as一断言,安全性和之前几乎一样。正确做法是先写isUser守卫再安全收窄,或者至少用结构上更严格的satisfies表达式做约束。

5. 工具链配置:用 ESLint 守住类型边界

光靠自觉很难长期维持代码质量,尤其团队人多的时候,总有人图省事写any。所以在项目里把 ESLint 规则配置好,让工具在 CI 阶段就把问题拦下来,比事后 review 靠谱得多。

5.1 推荐规则配置

核心是 TypeScript ESLint 提供的几个no-unsafe规则,它们专门针对any的类型逃逸行为:

{ "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-explicit-any": ["error", { "ignoreRestArgs": true }], "@typescript-eslint/no-unsafe-assignment": "error", "@typescript-eslint/no-unsafe-member-access": "error", "@typescript-eslint/no-unsafe-call": "error", "@typescript-eslint/no-unsafe-return": "error", "@typescript-eslint/no-unsafe-argument": "error" } }

逐个说下这些规则在防什么:

  • no-explicit-any:禁止显式写出any类型。ignoreRestArgs设为true是因为 rest 参数(比如...args: any[])在很多场景下是合理用法,可以放宽。
  • no-unsafe-assignment:禁止把any类型赋值给具体类型变量,堵住"从 any 流向安全类型"的口子。
  • no-unsafe-member-access:禁止访问any类型的属性,强制先收窄再使用。
  • no-unsafe-call:禁止把any当作函数调用。
  • no-unsafe-return:禁止函数返回any类型。
  • no-unsafe-argument:禁止把any作为参数传给函数。

这套规则刚开启的时候,存量代码会红一片,别慌。我建议先以warn级别跑一个迭代,手动清完一轮之后再把关键规则改成error。清完之后你会明显感觉到代码里"不确定的东西"变少了,凡是外部数据都有守卫兜底,内部逻辑也基本不依赖隐式any。

除了 ESLint,还可以配合编译配置里的noImplicitAny(或strict全家桶),把隐式any也一并禁掉:

{ "compilerOptions": { "strict": true, "noImplicitAny": true, "useUnknownInCatchVariables": true } }

useUnknownInCatchVariables值得单独说一句:开启之后,catch (e)里的e类型从any变成unknown,这会逼着你在 catch 分支里先判断错误类型再访问属性。很多人平时忽略了这个配置,其实它对错误处理的规范化帮助很大,比如区分Error实例和字符串错误,能让错误上报链路清晰不少。

5.2 一个适合推行的团队约定

配置规则之外,我还建议在代码规范里写清楚两句话:"新代码禁止出现 any,确需使用必须附带注释说明原因";"任何从外部进入的数据,必须通过类型守卫才能进入内部逻辑"。

这个约定看着简单,执行起来效果却很显著。之前有个项目,把any全面切换成unknown后,线上爆出的TypeError: Cannot read properties of undefined类错误下降了大概七成——不是因为代码逻辑变聪明了,而是因为每个不可信的数据入口都有了校验,错误在进入业务逻辑之前就被拦截了。

我在实际使用中还有一个体会:unknown带来的最大价值,不是"类型更安全"这个抽象概念,而是它逼着你去思考"数据到底长什么样"。很多any写多了的人,其实并不清楚接口返回的真实结构,反正取不到就在控制台里层层展开。换成unknown之后,你必须先花十分钟把数据结构理清楚,这十分钟花得远比后面两小时的排查值得。如果你正被一堆any折磨,建议从今天开始,把新写的函数签名全部换成unknown,一个月后再回头看老代码,你会感谢当初那个决定。

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

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

立即咨询