做TypeScript类型收窄的时候很多人会在Any、Unknown、Void这三个特殊类型上卡住。我自己也踩过不少坑最惨的一次是在老项目里把接口返回值全标成 any上线当天线上直接报Cannot read properties of undefined编译阶段一片祥和运行时当场翻车。后来才明白这三个类型不是简简单单的“任意值”、“未知值”和“空返回值”它们的边界、兼容规则还有实际用法藏着不少容易被忽略的细节。这篇文章就把我这几年在项目里用这三个类型的经验好好聊一聊。无论你是刚接触 TypeScript 的新手还是已经在写业务代码的中级开发者都能从中找到可以直接落地的写法。我们会先搞清楚每个类型到底解决什么问题再讲怎么用、什么时候用然后结合一些真实报错现场分析陷阱最后给出一套从 any 到 unknown 再到具体类型的迁移思路。1. 三个特殊类型到底解决了什么问题1.1 any类型系统的“逃生舱”为什么 TypeScript 会给一个 any 作为特殊类型因为它要解决一个很现实的问题类型系统不是万能的。总有一些场景比如从纯 JavaScript 库拿数据、解析 JSON、或者处理运行时的动态结构你没法在编译期知道它到底是什么类型。any 就是这样一个“逃生舱”只要你给它标上 any编译器会主动放弃对该值的类型检查。听起来很爽但代价也很大。看看这段代码let data: any; data 42; data hello; data.foo.bar(); // 编译能过运行必炸上面三行赋值没有任何问题因为 any 允许任意类型的任意操作。问题恰恰出在这种“过度自由”上——当你在data上访问foo.bar时编译器不会提醒你foo可能不存在也不会提示bar不是函数。这些错误会一路跑到运行时才爆发。但你也别急着判死刑。在下面三种场景里any 仍然有它的实用价值一是渐进式迁移老代码时先临时用 any 让项目跑起来再逐层替换成更具体的类型二是和没有类型声明的第三方 JS 库交互时可以用 any 暂时绕开缺失声明三是写测试 mock 数据时频繁构造对象容易啰嗦any 能省不少事。关键是要把它当成“权宜之计”而不是默认选项。1.2 unknown安全的“任意值”unknown 和 any 一样都可以用来表示“不确定的任意值”。但两者有一个本质区别unknown 虽然接受任意类型的赋值却不允许你随便读取属性或调用方法。这种限制让 unknown 成为“安全的 any”。我经常把 unknown 比作一个贴着“未确认物品”标签的快递箱。你知道箱子里有东西但在开箱验货之前你不能直接把它放到货架上卖也不能拆开就用。必须先经过检查确认它确实是你需要的东西才能进入下一步操作。TypeScript 里同样如此let result: unknown await fetchData(); // result.foo; // 这里会直接编译报错对象的类型为 unknown。 if (typeof result object result ! null) { const obj result as Recordstring, unknown; console.log(obj[name]); }对于从外界进入系统的数据比如接口响应、用户输入、配置文件内容一开始都用 unknown 接收再做一层一层的类型收窄可以大大减少运行时崩溃的概率。unknown 的强制约束看起来麻烦但它逼着你认真处理每一个可能的分支这恰恰是 TypeScript 最大的价值。1.3 void空返回值的标记void 在 TypeScript 里的含义是“函数没有返回值”。如果你有一个函数整个流程执行完不需要返回数据那么它的返回类型就可以标记为 void。最常见的写法是事件处理函数、定时器回调还有各种“只要执行、不用结果”的工具函数。这里有一个很多人搞不清的点void 和 undefined 并不等价。在 TypeScript 里你可以把一个undefined值赋给void类型但不能反过来把一个void类型的值赋给undefined。更微妙的是如果函数类型声明返回 void那么一个返回数字的函数也可以赋值给它因为调用方会忽略返回值。const voidFn: () void () 42; // 合法返回值被忽略这个规则在回调场景特别有用。比如数组的 forEach 回调你不关心它对每个元素返回了什么只关心它执行了逻辑所以返回 void 是合理且灵活的。2. 实操细节与判断标准2.1 any能用但别滥用三个场景才建议用我曾经在一个项目里统计过代码里大概有几百处 any。大部分不是真的需要而是写代码时图省事懒得想类型。后来我给自己定下规矩能用 unknown 明确收窄的就用 unknown能写具体类型的就写具体类型any 只留给以下三种场景。第一个场景是迁移老项目。把 JavaScript 文件改成 TypeScript 时如果一开始就要求每个函数都给出完整类型工作量会大到令人崩溃。比较务实的做法是先用严格模式编译再把一时半会儿改不完的模块标记成 any用 git 记录是哪个文件、哪个变量在欠技术债然后排期清理。第二个场景是第三方库没有类型声明。你可以写一个declare module some-lib;先应付过去但更好的做法是去 DefinitelyTyped 找 types 包或者自己写一个最小声明文件。第三个场景是测试数据构造。为了快速造一个 mock 对象类型写得太死反而会拖慢测试编写速度但测试代码里也要尽量收敛不该把 any 扩散到生产代码。// 临时应急的写法 const externalLibData: any someUntypedLib.getData(); // 之后要补的具体类型 interface ExternalLibData { id: string; name: string; }如果你发现自己在一个新项目里大量写 any那就要停下来反思了。与其给 any 找借口不如想想是不是 tsconfig 没有开strict或者团队里缺少明确的类型设计规范。2.2 unknown从外部进系统的数据一律走这里我现在的习惯是所有“外部输入”默认 unknown。所谓外部输入包括 HTTP 请求的返回值、用户的文本框内容、剪贴板内容、本地存储里读出来的数据、postMessage 传过来的消息等等。它们都有一个共同特点你以为是某个结构但运行时可能不是。unknown 配合类型守卫才能真正发挥威力。类型守卫就是那些能告诉 TypeScript“当前值已经被检查成某个具体类型”的函数或判断。基础判断有typeof、Array.isArray、in运算符复杂一点的可以自定义function isRecord(value: unknown): value is Recordstring, unknown { return typeof value object value ! null !Array.isArray(value); } function parseEventPayload(value: unknown): EventPayload { if (!isRecord(value)) { throw new Error(payload must be an object); } const type value[type]; if (typeof type ! string) { throw new Error(type must be a string); } return { type, data: value[data] }; }你可能会觉得这样写很累但相信我多写这几行判断能省掉无数个凌晨三点被报警电话叫醒的机会。处理外部数据时未知状态是常态提前假设它可能不合法系统才更抗造。2.3 void三种典型使用场景void 不只是一个简单的“没有返回值”标签在实际项目中它有几种典型用法值得仔细看。第一种自然是声明无返回值函数function logUserAction(action: string): void { console.log([action] ${action}); }第二种是作为回调的函数类型。很多框架的 API 会接受一个“不关心返回值”的回调比如 DOM 事件监听器。TS 里可以用返回 void 的函数类型来表示这种“可以忽略返回值”的语义type ClickHandler (event: MouseEvent) void; const handler: ClickHandler (event) { event.preventDefault(); // 这里如果返回 true也不会报错 };第三种是泛型约束里用 void 表示“任意类型但调用方不关注结果”。比如你写一个debounce函数泛型 T 可以代表原函数的返回值但为了让 debounce 后的函数也能用于“返回 void”的上下文可以设置一个默认值或用void参与约束。这类写法比较进阶但理解了“返回 void 可以被任意返回值替换”的规则后再看很多工具库的源码会顺畅很多。3. 从报错现场看三个类型的常见陷阱3.1 any 带来的“假安全”——编译过了上线就挂最典型的报错是运行时的TypeError: Cannot read properties of undefined (reading bar)。代码在编译阶段完全没有异常因为 any 本质上关闭了类型系统等于告诉 TypeScript“这里不用你管了”。等用户一操作数据形状和预期不一致整个页面直接白屏。我遇到过的一个实际场景是后端返回的用户详情里某个字段在特定条件下不存在。前端拿到字段后直接访问子属性结果只有某些账号会触发报错。因为接口被标成了 any编译器一点儿忙都帮不上。后来我改成用 unknown 接收再写校验函数逐层判断问题立刻消失了。排查这种问题其实也不难全局搜一下.any的类型标注重点看接口调用、第三方数据解析这几块。如果调用链非常长建议把 any 换成 unknown让编译器帮你标出访问线上可以继续编译的“危险点”。3.2 unknown 处理外部数据时的“校验地狱”使用 unknown 之后很多人会掉进另一个坑类型守卫写得太多太杂代码看起来像“校验地狱”。比如最简单的“拿一个字符串”你也要写三段判断是不是太夸张了这其实是对“安全边界”的理解问题。并非所有数据都需要你做完整校验关键要看它从哪来、会到哪去。如果是内部传参类型已经由编译期保证不需要太防御如果是外部输入那就得考虑“如果它不是我想的那样会怎样”。比如一个可能从线上 API 返回的配置name字段没有时你是希望程序直接崩溃还是显示一个默认文案用 unknown 可以逼你做出明确选择而不是稀里糊涂地把错误带到后面。再举个 catch 错误的例子。TypeScript 4.4 之后catch 的变量默认是 unknown这其实是件好事try { await request(); } catch (e: unknown) { if (e instanceof Error) { console.log(request error:, e.message); } else if (typeof e string) { console.log(request error string:, e); } else { console.log(unknown error:, e); } }你是不是经常看到后端返回502 Bad Gateway或者unknown error这些错误信息有时候是字符串有时候是对象通过 catch 的 unknown 去区分就非常合适。与其把所有错误都断言成 Error不如把它们都当作未知数据来处理。3.3 void 的函数兼容性问题void 的兼容规则经常让人困惑。我见过不少同事在“为什么这个函数能赋值给返回 void 的回调”上卡住也见过反过来使用时被报错的例子。关键在于 TS 对() void类型有特殊处理当你把一个返回非 void 类型的函数赋值给返回 void 的类型时TypeScript 允许发生因为调用方不会使用返回值。这是一种刻意设计的宽松规则极大方便了事件监听、迭代回调这类场景。但反过来不行。你不能把一个() void类型的函数赋值给() numbertype VoidFn () void; type NumberFn () number; const v: VoidFn () 42; // 合法返回值被忽略 const n: NumberFn v; // 报错类型 () void 不能赋值给类型 () number这背后的逻辑是如果调用方期望得到一个数字而你给它一个不返回任何东西的函数那大家都会用到一个undefined这不是期待的行为。所以记住一句话返回 void 可以容纳“有返回值”但反过来不行。void 值得当成“我不关心返回什么”而不是“一定没有返回值”。4. 项目实战安全使用三个类型4.1 从 any 到 unknown 再到具体类型的渐进迁移如果你现在手里有一个满是 any 的老项目别想着一天之内全部清理完。可以按三条线推进。第一条线是新增代码禁止使用 any。在代码评审里直接拦凡是新代码出现 any必须写注释说明为什么。团队如果配合得好可以在 CI 里加上no-explicit-any的 ESLint 规则从工具层面卡死。第二条线是把公共入口的类型从 any 改成 unknown。比如封装一个request函数返回类型从 Promiseany 改成 Promiseunknown然后让各业务模块自己去收窄。这个过程会产生大量编译报错但每一个报错都在帮你发现真正的数据依赖。第三条线是给高频使用的接口写类型守卫。把那些被十个页面重复调用的 API 响应结构先定义清楚写好守卫函数其他代码再逐步引用。迁移的时候可以做一个简单的状态表接口是否已被 unknown 接收、是否已定义类型守卫、是否已删除 any 标注。每完成一个就打勾心理压力小进度也清晰。4.2 高频工具类型与类型守卫封装项目中真正好用的东西往往是那些“一次编写、到处复用”的守卫函数。我一般会在src/utils/guards.ts里集中放一批常用守卫export function isRecord(value: unknown): value is Recordstring, unknown { return typeof value object value ! null !Array.isArray(value); } export function isString(value: unknown): value is string { return typeof value string; } export function isNumber(value: unknown): value is number { return typeof value number Number.isFinite(value); } export function isBoolean(value: unknown): value is boolean { return typeof value boolean; } export function getStringField(obj: unknown, key: string): string | undefined { if (!isRecord(obj)) { return undefined; } const value obj[key]; return isString(value) ? value : undefined; }有了这些基础函数很多解析逻辑会变得非常有条理。比如一段请求详情数据的代码可以这样组合使用function parseUserProfile(data: unknown): UserProfile { if (!isRecord(data)) { throw new Error(invalid profile); } const id getStringField(data, id); const name getStringField(data, name); if (!id || !name) { throw new Error(profile is missing required fields); } return { id, name }; }用 unknown 不是让你在每个函数里都做一大坨临时判断而是提前把公共能力沉淀下来新功能直接调用即可。4.3 配置 eslint 与团队约定工具配置在第一次推进会花一些时间但长期收益非常明显。我通常会在项目里这么配置{ rules: { typescript-eslint/no-explicit-any: error, 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 } }如果项目刚起步全部开成 error 可能会有很多存量报错可以先设成 warn或者暂时开allowExpressions: true等代码逐步收敛后再收紧。除了工具配置团队约定也很重要。我的个人习惯是任何函数的返回值都尽量写出明确类型内部函数之间的传参可以用具体接口但所有“外部边界”入口必须用 unknown函数明确不返回内容时写 void不要为了省事省略返回类型。5. 常见问题速查与避坑清单5.1 这些问题你很可能也遇到过场景推荐类型写法建议容易踩的坑接口返回数据结构未知unknown先用守卫收窄再访问直接断言成具体类型跳过运行时校验第三方库没有类型声明any临时使用后尽快补声明让 any 扩散到业务代码里函数不返回值void明确声明返回类型把 void 和 undefined 混为一谈回调不关心返回值() void允许返回任意值反过来赋值给需要返回值的类型catch 捕获的错误信息unknown用 instanceof / typeof 分别处理把e直接当 Error丢失信息mock 测试数据any 或自定义类型尽量保持与真实接口一致将测试里的 any 带入生产代码5.2 我个人踩过的坑我犯过最蠢的一个错误是在刚接触 unknown 时为了省事在类型守卫里到处用as any跳过检查。这等于一边喊着要安全一边自己把保险丝给拔了。后来我给自己定了规矩as any只能出现在守卫函数内部而且必须在注释里说明为什么这里必须用。另一个坑是把 void 当成 undefined 用。我曾经写过一段代码用一个变量保存“函数返回值”函数类型是() void结果运行时拿到的是 undefined还以为是逻辑 bug。后来才反应过来void 类型的变量不能直接赋值给undefined类型。TS 的兼容性检查虽然看着抽象但它背后是有实际场景在支撑的理解了场景规则就好记了。如果你现在也正在被这几个类型折磨我建议先不要急着写代码而是把项目里所有的外部数据入口列出来挨个检查它们的类型。你会发现很大一部分运行时崩溃的根源都可以通过把 any 改成 unknown再补上几个类型守卫来避免。我自己的项目实践下来改动量和线上 bug 数量的关系是“一减一增”的好趋势代码量虽然多了一点点但半夜被叫醒的次数明显少了。这三个类型看上去简单用好了就是类型系统里的安全阀。多花点时间理解它们真的不亏。