这个线上事故让我至今印象深刻:一个计费相关的接口,某天开始把一部分用户的金额算成了 NaN,客服工单瞬间堆满。诡异的是,我们整个项目是 TypeScript 写的,strict 模式全程开着,CI 里 tsc 一个 error 都没有——类型系统明明站在那儿,却眼睁睁放这个 bug 溜进了生产。排查到最后,根因小得让人窝火:上游一个外部 API 把字段从 userName 悄悄改成了 user_name,我们的代码还在读 resp.userName,读到 undefined,一路往下传,最后参与计算就成了 NaN。
问题来了:字段名都对不上了,号称"编译期就能抓住一切"的 TypeScript,为什么一声没吭?答案藏在那行 fetch 的返回值上——它的类型是 any。这篇就从这个"类型没拦住的 bug"讲起,把 TS 真正讲透:any 为什么是个会传染的黑洞、unknown 凭什么是它的正确替身、类型收窄和判别联合怎么让编译器替你兜底、以及最关键的一课——类型只活在编译期,外部数据必须在运行时校验。想明白这些,这类"类型形同虚设"的诡异 bug,你一眼就能看穿。
先认清:类型检查是怎么被你亲手关掉的
很多人以为"我开了 strict、tsc 不报错,类型就安全了"。但 TypeScript 的类型检查有好几个后门,一旦走了后门,编译器就对那块代码彻底闭眼——而且你往往是无意中走的。先把这几个后门摆出来:
| 关掉类型检查的后门 | 真相 | 后果 |
|---|---|---|
| 变量/返回值是 any | any 会向外传染,沾上它的表达式全部失去检查 | 字段写错、拼错全不报错,运行时炸 |
| 用 as 强制断言"我保证它是这个类型" | 断言只是骗编译器,不做任何运行时检查 | 断言一旦说谎,bug 直达生产 |
| // @ts-ignore 压住红线 | 它只是把错误藏起来,问题原封不动 | 下次重构时这里第一个崩 |
| 外部数据直接当成已知类型用 | 类型只在编译期,运行时数据可以是任何形状 | API 一改字段,前端静默出错 |
| 索引访问 arr[i] 默认当成一定存在 | 越界拿到的是 undefined,类型却标成 T | undefined 顺着类型缝隙溜下去 |
这张表里的每一条,我们项目都中过招,而那次计费事故正是第一条和第四条的合谋:fetch().then(r => r.json()) 的返回是 any,我们又直接拿它当成了一个有 userName 字段的对象。类型系统不是没工作,是我们用 any 把它的眼睛蒙上了。下面先看 any 这个黑洞到底怎么传染,再逐个拆解怎么把这些后门一一焊死。
第一件事:any 是个会传染的黑洞
any 最危险的地方不是它本身,而是它的传染性:任何表达式只要碰了 any,结果也变成 any,检查就一路失效下去。它不是"这一个变量不检查",而是"以它为起点的一整条数据流都不检查":
// fetch 的 json() 返回 any —— 黑洞的起点
const resp = await fetch('/api/user').then(r => r.json()); // resp: any
// 下面这些全都不会报错,因为 any 沾到哪、哪里就失去检查
const name = resp.userName; // any:就算字段叫 user_name 也不报错
const age: number = resp.profile.age.whatever; // any:再离谱的链式访问也放行
const total = resp.price * resp.count; // any:乘出来还是 any
// 字段名拼错、层级写错、类型不匹配……编译器全程沉默
// 直到运行时,name 是 undefined、total 是 NaN,才轰然爆炸
sendBill(name, total); // 把 undefined / NaN 一路传下去
看明白没有:any 不是一个"不安全的点",而是一条"不安全的河"——从 resp 这个源头开始,所有顺流而下的访问、运算、传参,全都被它污染成了"编译器不管"的状态。这正是为什么 strict 全开、tsc 全绿,bug 还是溜了进去:类型检查根本没覆盖到这条河。画成图,这个"绕过"的过程是这样的:
注意那条虚线——any 给你的"方便",代价是把编译期的防线整段拆掉,让 bug 一路畅通无阻地滑到线上。治本的第一步,就是把所有进入系统的外部数据,从 any 换成它那个"看起来一样、行为却天差地别"的兄弟:unknown。下一节就讲这个关键替换。
第二件事:用 unknown 替代 any,逼自己先收窄再用
unknown 是 TypeScript 给"我现在还不知道它是什么"准备的安全类型。它和 any 看着像,本质却完全相反:any 是"随便你怎么用,我都不管";unknown 是"你想用?先证明它是什么再说"。把上面那条黑洞河的源头改成 unknown,编译器立刻就回到岗位上:
// 把外部数据的入口类型从 any 改成 unknown
const resp: unknown = await fetch('/api/user').then(r => r.json());
// 现在再想直接访问字段,编译器立刻拦住你:
const name = resp.userName;
// ~~~~ Error: 'resp' is of type 'unknown',不能直接访问属性
// unknown 强制你先"收窄"——先检查它的形状,确认后才能安全地用
if (resp && typeof resp === 'object' && 'userName' in resp) {
// 在这个分支里,编译器知道 resp 至少有 userName 字段了
console.log((resp as { userName: string }).userName); // 此处的断言是收窄后的、安全的
}
区别一目了然:any 让你"想当然地用",unknown 逼你"检查后再用"。一个把责任推给运行时(炸了再说),一个把责任拉回编译期(写的时候就得想清楚)。一条铁律:所有进入系统边界的数据——fetch 结果、JSON.parse 返回、localStorage 读出的字符串、catch (e) 里的 e——默认都该是 unknown,而不是 any。把 any 全局搜出来换成 unknown,是给一个老项目加固类型安全最高效的一步。
第三件事:类型收窄,让编译器在每个分支里都"更懂"
有了 unknown,接下来的核心技能就是类型收窄(narrowing):通过条件判断,让编译器在某个代码块内把一个宽泛的类型缩小成更具体的类型。TS 的强大之处在于,它能读懂你的 if、typeof、in,并在对应分支里自动收窄:
// 1) typeof 收窄:区分基本类型
function format(x: string | number): string {
if (typeof x === 'number') {
return x.toFixed(2); // 这个分支里 x 一定是 number,能调 toFixed
}
return x.trim(); // 走到这里 x 一定是 string,能调 trim
}
// 2) in 收窄:靠字段存不存在区分对象
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function speak(animal: Dog | Cat) {
if ('bark' in animal) animal.bark(); // 收窄成 Dog
else animal.meow(); // 收窄成 Cat
}
// 3) 自定义类型守卫:把"判断逻辑"封装成可复用的 is 函数
function isUser(x: unknown): x is { userName: string } {
return !!x && typeof x === 'object' && 'userName' in x
&& typeof (x as any).userName === 'string';
}
if (isUser(resp)) {
resp.userName; // ✅ 编译器信任 is 的结论,这里 resp 已被收窄,无需再断言
}
第三种——用户自定义类型守卫(x is T)——是最值钱的:它把"怎么判断这是个 User"这件事封装成一个返回 x is User 的函数,调用处只要进了 if,编译器就把 resp 当成 User 来对待,后面再访问字段一律安全、也不必到处写 as。收窄的精髓:不是用断言去"压制"编译器,而是用它能理解的判断去"说服"编译器。说服它之后,它反过来会替你在整个分支里站岗。
第四件事:判别联合,把"漏处理一种情况"变成编译错误
处理多种形态的数据时,最容易出的 bug 是"加了新类型,却忘了在某个 switch 里处理它"。判别联合(discriminated union)配合 never 的穷尽检查,能让这种"漏掉一种情况"直接变成编译期红线,根本溜不到运行时:
// 每个成员都带一个共同的"判别字段" kind,形状各不相同
type Result =
| { kind: 'success'; data: string }
| { kind: 'error'; message: string }
| { kind: 'loading' };
function render(r: Result): string {
switch (r.kind) {
case 'success': return r.data; // 这里能安全访问 data
case 'error': return r.message; // 这里能安全访问 message
case 'loading': return '加载中...';
default:
// 穷尽检查:如果上面漏处理了某个 kind,r 在这里就不是 never,
// 这行赋值会报编译错误,逼你回去补上遗漏的分支
const _exhaustive: never = r;
return _exhaustive;
}
}
这个 const _exhaustive: never = r; 是整段代码的灵魂:只要所有 case 都处理到了,走到 default 时 r 的类型会被收窄成 never,赋值成立;可一旦你给 Result 新增了一个 { kind: 'timeout' } 却忘了加 case,r 在 default 里就还剩 timeout 那一支,无法赋给 never,编译器当场报错,把"未来某天才会触发的漏处理 bug",提前到你敲代码的此刻就拦下。这是 TS 把"运行时风险"转成"编译期错误"最漂亮的一招。
第五件事:最关键的一课——外部数据必须在运行时校验
前面四件事都在编译期发力,但那次计费事故藏着一个更根本的真相:TypeScript 的类型只存在于编译期,编译完就被擦掉了,运行时一行类型代码都没有。也就是说,你写 resp: User 只是在承诺"我相信它是 User",运行时根本没人替你核对。一旦上游 API 改了字段,这个承诺当场作废,而代码毫不知情。对所有跨越系统边界的数据,唯一可靠的做法是在运行时真刀真枪地校验一遍。用 zod 这类校验库最顺手:
import { z } from 'zod';
// 定义一份"运行时也认账"的 schema,它既校验数据、又能推导出 TS 类型
const UserSchema = z.object({
user_name: z.string(), // 注意:这里按 API 真实字段名来写
age: z.number().int().nonnegative(),
vip: z.boolean().default(false),
});
// 让 TS 类型直接从 schema 推导出来,类型和校验规则永远是同一份、不会跑偏
type User = z.infer<typeof UserSchema>;
async function getUser(): Promise<User> {
const raw: unknown = await fetch('/api/user').then(r => r.json());
// parse 会在运行时逐字段核对,字段缺失/类型不符当场抛错,而不是把 undefined 放过去
return UserSchema.parse(raw); // 校验通过后,返回值就是干净、可信的 User
}
这一步是整篇文章的题眼:如果当初 fetch 的结果过了一道 UserSchema.parse,那次字段从 userName 改成 user_name 的变更,会在解析的那一刻就抛出"缺少 user_name 字段"的明确错误,而不是悄无声息地把 undefined 一路传成 NaN。更妙的是 z.infer 让"运行时校验规则"和"编译期类型"共用同一份定义——你再也不会出现"类型写对了、校验忘了改"的脱节。记住这条分界线:系统内部尽情相信类型,系统边界一律运行时校验。
第六件事:泛型别用 any 兜底,用约束把类型"串起来"
最后一个高频翻车点是泛型。很多人写泛型时一遇到"类型对不上"就退回 any,等于白写。泛型的正确用法是用 extends 给类型参数加约束,让输入和输出的类型严丝合缝地关联起来:
// 反例:用 any 兜底,调用方拿到的返回值丢失了所有类型信息
function pluckBad(obj: any, key: string): any {
return obj[key]; // 返回 any,黑洞又回来了
}
// 正解:用约束泛型,把"对象类型"和"键类型"绑定,返回值类型自动精确推导
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]; // 返回类型精确到 T[K]
}
const user = { name: 'Ada', age: 30 };
const a = pluck(user, 'name'); // a 的类型被精确推导为 string
const b = pluck(user, 'age'); // b 的类型被精确推导为 number
const c = pluck(user, 'email');
// ~~~~~~~ Error: 'email' 不是 user 的键,编译期就拦下
K extends keyof T 这个约束是关键:它告诉编译器"key 只能是 obj 真实存在的键之一",于是写错键名当场报错,返回值类型也能精确到 T[K]。泛型的价值,是让类型信息流动起来、而不是用 any 把它截断。每当你想在泛型里写 any 兜底时,先停一下想:是不是该用一个 extends 约束、或者多加一个类型参数,把这层关系表达清楚?
把整套判断收成一棵决策树
把前面六件事串起来,下次你拿到一个值、不确定该怎么给它定类型时,照着这棵树走,基本不会再给 bug 留缝:
这棵树的总开关,就是开头那个血泪教训:看到外部数据,第一反应必须是"它是 unknown,我得先校验",而不是"它大概是 User,我直接用"。这一个分叉,就避开了本文最大的那个"类型形同虚设"的坑。
收口成几条 TypeScript 的铁律
- 把 any 当违禁品:它会传染,一沾上就让整条数据流失去检查;能不用就不用,实在不知道类型就用
unknown。 - 外部数据入口一律 unknown:
fetch、JSON.parse、localStorage、catch (e)的结果默认都是unknown,逼自己先收窄再用。 - 用判断"说服"编译器,而非用 as "压制"它:优先
typeof/in/ 自定义类型守卫x is T来收窄,少写裸as断言。 - 多形态数据用判别联合 + never 穷尽检查:让"漏处理一种情况"变成编译错误,而不是运行时惊喜。
- 系统边界必须运行时校验:类型只在编译期,跨边界的数据用
zod等校验,并用z.infer让类型与校验规则同源。 - 泛型用 extends 约束,别用 any 兜底:让类型信息在输入输出间流动,而不是被
any截断。 - tsc 全绿不等于安全:
any、as、@ts-ignore都是关掉检查的后门,绿色只代表"没走后门的地方没问题"。
几个特别容易踩的认知误区
这套经验讲给同事时,有几个误区几乎人人都有,值得专门点破。
第一个、也是最致命的:"开了 strict、tsc 不报错,类型就肯定安全了。" 这正是那次事故的认知根源。strict 管的是"你诚实写出来的类型之间有没有矛盾",但它管不了 any 这种主动弃权、也管不了 as 这种主动撒谎,更管不了运行时数据到底长什么样。tsc 全绿只意味着"在你没走后门的范围内自洽",而 bug 偏偏最爱从后门溜进来。
第二个误区:"unknown 用起来太麻烦,到处要收窄,不如 any 爽。" 这恰恰说反了——unknown 的"麻烦"正是它的价值:它把"你必须想清楚这数据是什么"这件事强制提前到编译期。any 的"爽"是把这份思考推给运行时,代价是某天线上炸了你再花两天来 debug。麻烦在前还是麻烦在后,这是笔很好算的账。
第三个误区:"用 as 断言一下就好了,反正我知道它是什么类型。" as 不做任何运行时检查,它只是让编译器闭嘴。"我知道它是什么"这句话,在 API 改字段、在数据格式微调、在别人改了上游代码之后,随时会变成假话——而 as 会让这个假话一路畅通无阻。需要"断定类型"时,优先用做了真实判断的类型守卫,而不是空口无凭的 as。
第四个误区:"TypeScript 编译完会做运行时检查吧?" 不会,一点都不会。类型信息在编译成 JS 的那一刻被完全擦除,产物里没有任何类型校验代码。这是最该刻进骨子里的一条:类型是给编译器和你看的契约,不是运行时的保镖。想要运行时的保镖,得自己请(zod 这类校验库)。
再补两个让类型更稳的实用招
把前面六件事落地后,还有两个我们项目后来加进规范、性价比极高的小习惯,顺带说一说。
第一个是开启 noUncheckedIndexedAccess。还记得开头那张表的最后一行吗?默认情况下,arr[i] 或 obj[key] 即便越界、即便键不存在,TS 也把结果当成 T(而不是 T | undefined),于是 undefined 又能顺着这条缝隙溜进来。打开这个编译选项,索引访问的结果会自动带上 undefined,逼你处理"取不到"的情况:
// tsconfig.json: { "compilerOptions": { "noUncheckedIndexedAccess": true } }
const list: string[] = ['a', 'b'];
const first = list[0]; // 类型变成 string | undefined,而不是 string
// 编译器逼你先确认它存在,再用
console.log(first.toUpperCase());
// ~~~~~ Error: 'first' 可能是 undefined
if (first !== undefined) {
console.log(first.toUpperCase()); // ✅ 收窄后安全
}
它会让你多写一些判空,但换来的是"越界访问"这类 bug 在编译期就被摁住——对处理动态数据、map 查表的代码尤其值。
第二个是用 satisfies 代替 as 给对象字面量定型。as 是"强行声称",会掩盖错误;而 satisfies 是"校验它符合某类型,但保留更精确的字面量推导",既检查了又不丢类型信息:
type Config = Record<string, { port: number }>;
// 用 as:写错了也不一定报错,还会把精确类型抹平
const bad = { web: { port: '80' } } as Config; // port 写成字符串,as 却放过了
// 用 satisfies:既校验符合 Config,又保留每个 key 的精确推导
const good = {
web: { port: 80 },
api: { port: 8080 },
} satisfies Config;
// port: '80' 这种写错,satisfies 会当场报错;同时 good.web 的类型仍被精确推导
good.web.port; // number,精确;若用 as Config 则只会是宽泛的索引类型
一句话记牢这两者的分工:想"断定"一个值是什么类型、又不想丢精度,用 satisfies 让编译器帮你核对;只有在你确实比编译器更懂、且愿意自担风险时,才退而用 as。能用 satisfies 的地方就别用 as,这又是一道把"撒谎式断言"挡在门外的纪律。
顺手说说工具类型:别再手抄一份"几乎一样"的类型
还有一个让类型既准确又好维护的关键习惯:用内置工具类型从已有类型"派生"新类型,而不是手写一份几乎重复的。我见过太多代码,定义了一个 User,转头又手抄一个 UserUpdateForm(所有字段变可选)、再抄一个 UserListItem(只留几个字段)——三份类型各写各的,改一处忘两处,过段时间就彼此脱节、又给 bug 留了缝。其实一行派生就够了:
interface User {
id: number;
name: string;
email: string;
age: number;
}
// Partial:所有字段变可选 —— 适合"更新表单",只改部分字段
type UserUpdate = Partial<User>; // { id?, name?, email?, age? }
// Pick:只挑出需要的字段 —— 适合列表项,不暴露多余信息
type UserListItem = Pick<User, 'id' | 'name'>; // { id, name }
// Omit:排除某些字段 —— 适合"创建时还没有 id"的场景
type UserCreate = Omit<User, 'id'>; // { name, email, age }
// Readonly:全字段只读 —— 适合不该被修改的配置/快照
type FrozenUser = Readonly<User>;
这些派生类型的妙处在于:它们和源类型 User 永远保持同步。哪天 User 加了个 phone 字段,UserUpdate 自动多出可选的 phone,UserCreate 自动要求 phone——你一处都不用改,也绝不会出现"主类型改了、派生类型忘了改"的脱节。一条原则:类型之间但凡存在"从属/变形"关系,就用 Partial / Pick / Omit / Record 这些工具类型派生出来,而不是复制粘贴一份再手动改。复制粘贴出来的类型,本质上和开头那个 any 一样——都是迟早会和真相对不上的"谎言",只是它撒谎撒得更慢、更隐蔽而已。
最后一个小招:as const 锁死字面量
再补一个常被忽略、却特别提升类型精度的小工具:as const。默认情况下,TS 会把字面量"放宽"成宽泛类型——写 const role = 'admin' 推成 string,写一个配置数组推成可变的 string[]。加上 as const,它会被锁成最精确、且只读的字面量类型:
// 不加 as const:roles 被推成 string[],元素类型只是宽泛的 string
const roles = ['admin', 'editor', 'viewer']; // string[]
// 加上 as const:锁成只读元组,且每个元素都是精确的字面量
const ROLES = ['admin', 'editor', 'viewer'] as const;
// 类型为 readonly ['admin', 'editor', 'viewer']
// 这样就能从值反推出精确的联合类型,值和类型再不会脱节
type Role = typeof ROLES[number]; // 'admin' | 'editor' | 'viewer'
function setRole(r: Role) { /* ... */ }
setRole('admin'); // ✅
setRole('root'); // ✗ 编译期报错:'root' 不在允许的角色里
as const 让你"定义一次值,就自动得到对应的精确类型",再也不用手写一遍 type Role = 'admin' | 'editor' | 'viewer' 然后祈祷它和数组别写岔了。这又呼应了贯穿全文的那个思路:让类型从单一事实来源派生,杜绝任何"两份定义对不上"的缝隙。
写在最后
回到开头那个算出 NaN 的计费接口。最终的修复,是给那个 fetch 加了一道 zod 校验,把入口类型从 any 改成 unknown,再顺着调用链把几处裸 as 换成了类型守卫——上线之后,上游再改字段,我们会在解析那一刻收到一条清清楚楚的报错,而不是等用户账单算错了才从客服工单里倒查。改动不大,可它逼着我把 TypeScript 那条"编译期 / 运行时"的分界线,从头到尾真正想明白了一遍。
这件事给我最深的体会是:TypeScript 给的安全感太足,足到让人忘了它只在编译期有效、足到让人随手一个 any 就把它的保护整段拆掉而毫无知觉。它不是"加了类型注解就万事大吉"的魔法;它是一套需要你主动维护的契约——你诚实地描述类型、不走后门、并在边界处亲自核对,它才会在编译期忠诚地替你站岗。下次你又想图省事敲下一个 any 或者 as 的时候,想想我那个算成 NaN 的账单——那一个偷懒的关键词,可能就是你下个月某个深夜被客服工单叫醒的原因。
—— 别看了 · 2026