我们的前端项目是 TypeScript 写的,团队一直引以为豪:有类型保护,编译器把关,线上应该很稳。直到某天,一个核心页面在生产环境白屏了,控制台里赫然一行红字:Uncaught TypeError: Cannot read properties of undefined (reading 'name')。我当时第一反应是难以置信——这不正是 TypeScript 该帮我挡住的那类错误吗?编译明明一片绿灯,tsc 没报任何问题,怎么还会在运行时栽在"读取 undefined 的属性"上?
顺着报错扒下去,真相让我有点脸红:出事的那段代码,从一个接口拿回数据后,顺手写了 const user = res.data as User;,然后就大大方方地 user.profile.name 一路点下去。问题是,那次后端因为某种边界情况,返回的 data 里压根没有 profile 字段。可那句 as User 的类型断言,等于我对编译器拍胸脯保证"这玩意儿一定是 User",编译器信了,于是一路放行——它根本没有、也无法在运行时去核实我这个保证到底成不成立。
这就是 TypeScript 最容易让人产生错觉的地方:它的类型系统是编译期的,编译完类型信息就被抹掉(类型擦除),运行时根本不存在。而 any 和 as 这两个口子,又能让你随时绕过编译器的检查,亲手把"类型安全"这道防线捅出一个洞。我那次,就是用一句 as User 给来路不明的外部数据发了张"免检通行证"。这篇文章,就从这次"编译全绿、线上白屏"的事故出发,把 TypeScript 里那些让类型安全形同虚设的坑,一次掀开。
先摆几个关于 TypeScript 的想当然
动手复盘前,先把我自己曾经笃信、后来被现实打脸的几个念头列出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "用了 TS,运行时就不会有类型错误了" | 类型只在编译期存在,运行时被擦除,挡不住外部脏数据 |
| "as 断言能把数据'转换'成那个类型" | as 只是骗编译器闭嘴,不做任何运行时转换或校验 |
| "接口返回的数据,类型标了就是对的" | 后端返回什么 TS 无从知晓,标注只是你的一厢情愿 |
| "any 用一下没关系,赶进度嘛" | any 会像病毒一样扩散,所到之处类型检查全部失效 |
| "编译通过 = 代码安全" | 编译通过只代表类型自洽,不代表运行时数据真长那样 |
这些念头的共同病根,是把 TypeScript 的类型系统当成了一道运行时的护栏,以为它能在程序跑起来时拦下脏数据。可实际上,它是一道编译期的护栏:它能在你写代码、编译的那一刻帮你发现自相矛盾的类型用法,却对运行时真正流进来的数据一无所知。要理解这次事故,得先认清这条"编译期 vs 运行时"的分界线。
第一件事:类型擦除——运行时根本没有"类型"这回事
TypeScript 最核心、也最容易被忽略的一个事实是:它编译成 JavaScript 后,所有的类型信息都被彻底抹掉了,这叫"类型擦除(type erasure)"。你写的 interface User、: string、<T> 泛型,在编译产物里一个都不剩,跑在浏览器里的就是一份普普通通、没有任何类型概念的 JavaScript。
这意味着一件很关键的事:类型检查只发生在编译那一刻。编译器在编译时,依据你写的类型标注,检查代码内部用得对不对、有没有自相矛盾。可一旦编译完成、代码运行起来,就再没有任何"类型"在背后帮你把关了。一个接口返回的 JSON、一段 localStorage 读出的字符串、一个用户输入的表单——这些来自程序外部的数据,运行时长什么样,编译器在编译时根本无从知晓,自然也无法替你保证。下面这张图,把这条分界线画出来:
看懂这张图,我那次事故的位置就清楚了:它根本不在左边的编译期(那里一路绿灯),而在右下角——外部接口返回了不符合 User 形状的数据,运行时却没有任何人去核实,于是 user.profile.name 直接炸了。TypeScript 守得住"你自己代码内部的一致性",守不住"外部世界递进来的数据"。这道边界上的防守,得靠我们自己补。接下来,先看那两个最容易自己拆掉护栏的动作。
第二件事:as 断言不是"转换",而是"让编译器闭嘴"
先说直接闯祸的那个:类型断言 as。很多人(包括当年的我)误以为 res.data as User 是在"把数据转换成 User 类型"。大错特错。as 不做任何运行时的转换、校验、转型,它纯粹是对编译器说一句:"这块数据是什么类型,别检查了,听我的。"编译器于是闭嘴放行——但运行时那块数据到底长什么样,跟你这句断言毫无关系。
// 反例:as 断言, 等于向编译器拍胸脯保证, 却无任何运行时核实
const res = await fetch('/api/user').then(r => r.json());
const user = res.data as User; // "相信我, 这是 User" —— 但万一不是呢?
console.log(user.profile.name); // data 里没 profile → 运行时 TypeError
// 更危险的是 as 还能层层"强转", 把明显不对的也压下去
const x = ('hello' as unknown) as number; // 编译器被骗过, 运行时是个字符串
这就是 as 的本质:它是一张你亲手签发的"免检通行证"。当你对外部数据使用它时,等于在类型安全这道墙上凿了个洞——编译器以为墙是完好的,数据却能从洞里大摇大摆地溜进来,直到运行时撞上 .profile.name 才暴露。记住:as 不创造安全,它只是消除编译器的警告;你越是对来路不明的数据用它,就越是在自欺欺人。真正该做的,是在数据入口处做运行时校验(后面会讲),而不是用 as 把疑虑一键消音。
第三件事:any 是会传染的,它所到之处类型检查全失效
另一个慢性杀手是 any。赶进度时,一句 const data: any = ... 确实能让烦人的类型报错瞬间消失,无比丝滑。但 any 的真正可怕之处在于它会传染:任何从 any 派生出来的值,默认也是 any,于是类型检查像被戳破的气球一样,从那个点开始一路漏气,沿着数据流扩散到下游,把本该受保护的代码也变成"裸奔"。
// 反例:一个 any, 污染一整条链路
function parse(input: any) { // any 入口
return input.data.list; // 这里点什么都不报错(危险的"自由")
}
const list = parse(raw); // list 被推断为 any
list.forEach(x => x.whatever()); // 全程无类型保护, 错了也编译通过
// 正解:用 unknown 替代 any —— 它强制你先收窄类型才能使用
function parseSafe(input: unknown) {
// 直接 input.data 会编译报错: unknown 必须先判断/收窄
if (typeof input === 'object' && input !== null && 'data' in input) {
// 在这个分支里, 类型才被安全地收窄
// ... 进一步校验后再使用
}
}
这里的关键替代品是 unknown。它和 any 一样能"先接住任何类型的值",但本质相反:any 是"我放弃检查,随便用";unknown 是"我先不知道它是什么,用之前必须先收窄、先证明它是什么"。换句话说,any 把责任丢给运行时(然后炸),unknown 把责任压回编译期(逼你处理)。处理外部数据时,入口类型用 unknown 而非 any,是把"裸奔"改成"先穿好衣服再出门"的关键一步。
配置上也该把口子收紧:tsconfig.json 里开启 strict(尤其是 noImplicitAny 和 strictNullChecks),再用 ESLint 的 no-explicit-any 规则,让每一个 any 都被显式标红,逼自己正视它,而不是让它悄无声息地蔓延。
第四件事:在数据入口处做"运行时校验",这才是根治
前面铺垫了这么多,根治之道终于浮出水面:既然类型在运行时被擦除、编译器管不到外部数据,那就在外部数据进入系统的那一刻,用真正的运行时代码去校验它的形状。校验通过,才赋予它对应的类型;校验不过,就在入口处就地拦截、给出明确错误,而不是让它带着隐患一路流到 UI 层才爆炸。
手写校验当然可以,但繁琐易漏。社区的成熟做法,是用 zod 这类"运行时 schema 校验"库:你定义一份 schema,它既能在运行时真刀真枪地校验数据,又能自动推导出对应的 TS 类型——一份定义,同时守住编译期和运行时两端。
import { z } from 'zod';
// 定义 schema: 它就是"运行时的类型"
const UserSchema = z.object({
id: z.number(),
profile: z.object({
name: z.string(),
}),
});
// 从 schema 自动推导出 TS 类型, 无需再手写 interface
type User = z.infer<typeof UserSchema>;
async function fetchUser(): Promise<User> {
const res = await fetch('/api/user').then(r => r.json());
// 关键:parse 会在运行时真正校验; 数据不合格立即抛错, 就地拦截
return UserSchema.parse(res.data);
// 若 data 里没有 profile, 这里就会抛出清晰的校验错误,
// 而不是把脏数据放进去, 等到 user.profile.name 才神秘崩溃
}
这套做法的精髓,是把"信任边界"明确地画在系统的入口:外部数据一律视为不可信的 unknown,经过 schema 校验这道闸门,才"升级"为可信的、带类型的数据。闸门内部,你可以放心享受 TS 的类型保护;闸门之外的脏数据,根本进不来。我那次的事故,只要在 fetchUser 里加上这道 parse,就会在数据入口处以一条清晰的报错被拦下,而不是在遥远的渲染层白屏。
第五件事:用类型守卫,让收窄"有运行时依据"
不想引入库时,TypeScript 自带的类型守卫(type guard)也能优雅地完成收窄。它的妙处在于:你写一个返回 x is SomeType 的函数,把运行时的真实检查和编译期的类型收窄绑定在一起——运行时真的查过了,编译器才认这个类型。
// 自定义类型守卫: 返回值写成 `参数 is 类型`
function isUser(x: unknown): x is User {
return (
typeof x === 'object' && x !== null &&
'profile' in x &&
typeof (x as any).profile?.name === 'string'
);
}
const data: unknown = await fetchSomething();
if (isUser(data)) {
// 在这个分支里, data 被安全收窄为 User —— 且有运行时检查兜底
console.log(data.profile.name); // 安全, 因为真的验证过了
} else {
// 数据不合格的明确分支, 在这里处理错误
showError('用户数据格式异常');
}
类型守卫和 as 形成了鲜明对比:as 是"我说它是,你别查";类型守卫是"我真的查了,所以它是"。前者是空头支票,后者有运行时背书。养成用类型守卫 + 收窄取代裸 as的习惯,是把"自欺欺人"换成"言之有据"的关键转变。
第六件事:打开 strictNullChecks,让 null/undefined 无所遁形
我这次事故的直接报错是"读取 undefined 的属性",而这类问题,TypeScript 其实有能力在编译期帮你挡住——前提是你打开了 strictNullChecks。它默认在 strict 模式里启用,作用是把 null 和 undefined 从"任何类型都默认包含它们"中拎出来,变成必须被显式处理的独立类型。开启后,凡是可能为空的值,你不处理就编译不过。
// 开启 strictNullChecks 后, 可能为空的值必须先处理
interface Order { coupon?: Coupon; } // coupon 可能不存在
function getDiscount(order: Order): number {
// 直接 order.coupon.amount 会编译报错: coupon 可能是 undefined
// 必须先判断 / 用可选链 + 空值合并
return order.coupon?.amount ?? 0; // 优雅地兜底
}
配合可选链 ?. 和空值合并 ??,大量"读取 undefined 属性"的运行时崩溃,都能被提前消灭在编译期。把 strict 全家桶打开,是用好 TypeScript 性价比最高的一步——很多团队为了"少报点错"图省事关掉它,等于花钱买了把好锁却不上,实在可惜。到这儿,这次事故的系统性解法就齐了。我把排查思路收成一张决策图:
把这套体系建起来,"编译全绿、线上白屏"的尴尬就能被牢牢兜住。最后,拧成几条可直接照做的铁律:
- 牢记类型在运行时被擦除,TS 守编译期一致性,守不住外部脏数据。
- 把外部数据一律当
unknown,在入口处用 zod 等做运行时校验,过闸才升级为可信类型。 - 慎用
as,它只让编译器闭嘴、不做任何核实,尤其别对外部数据裸用。 - 用
unknown替代any,逼自己先收窄再使用,别让 any 传染整条链路。 - 用类型守卫(
x is T)做收窄,让类型有运行时检查背书,而非空头承诺。 - 打开
strict全家桶,尤其strictNullChecks,配合?.和??提前消灭空值崩溃。 - 用 ESLint 把
no-explicit-any、no-unsafe-*规则开起来,让危险写法在评审前就被标红。
一张 TypeScript 类型安全速查表
把这些坑、根因和正确做法汇成一张表,贴在手边随时对照。
| 危险写法 | 问题 | 正确做法 |
|---|---|---|
data as User(对外部数据) |
无运行时校验, 脏数据直接放行 | zod 校验 / 类型守卫 |
: any |
关闭检查且会传染 | 用 unknown + 收窄 |
关掉 strict |
空值、隐式 any 不再报错 | 开启 strict 全家桶 |
obj.a.b.c 直接深取 |
中间为空就崩 | obj.a?.b?.c ?? 默认值 |
信任 JSON.parse 的结果 |
返回 any, 形状不可信 | parse 后立即 schema 校验 |
非空断言 obj!.x |
和 as 一样是空头保证 | 显式判空或可选链 |
盲信第三方 @types |
类型声明可能与实际不符 | 边界处仍做校验, 别全信 |
一个延伸提醒:第三方类型声明也可能"撒谎"
修好自己的代码后,我又意识到一个更隐蔽的信任漏洞:第三方库的类型声明(@types/xxx 或库自带的 .d.ts),本身也只是一份"声明",不保证和库的实际运行时行为一致。这些声明可能由社区手写、可能滞后于库的版本更新、也可能本就写得不够严谨——它标着某函数返回 string,实际却可能在某些情况下返回 undefined。
这意味着,.d.ts 给你的"类型安全感",有时是一种未经核实的二手承诺。编译器完全信任这份声明,可声明对不对,编译器无从判断。所以在调用第三方库、尤其是处理它返回的数据时,如果这数据会流向关键路径,最好仍然保持一分警惕:别因为"它的类型声明说是 string"就完全放心,该判空判空,该校验校验。
这其实是前面那条主线的自然延伸:类型标注(无论是你写的还是别人写的)都只是"声明的意图",而非"运行时的事实"。凡是跨越信任边界的数据——来自网络、来自存储、来自用户、来自第三方库——都不该仅凭一纸类型声明就放行。把"声明"和"事实"在脑子里分开,你对类型安全的理解就到位了。
两个常被忽略的破洞:JSON.parse 与数组下标
主干修完,我又在代码里翻出两个特别容易被放过、却同样能引发运行时崩溃的破洞,顺手补在这里。
第一个是 JSON.parse 的返回值。它的类型签名返回 any——又是 any!这意味着你 const obj = JSON.parse(str) 之后,obj 就成了一个不受任何检查的"自由人",随便点什么属性编译器都不拦。从 localStorage、从消息队列、从配置文件读出来的 JSON 字符串,经 JSON.parse 一转,就是一个个潜伏的脏数据入口。正确做法和处理接口数据一样:parse 完立刻校验。
// 反例:JSON.parse 返回 any, 之后一路裸奔
const cfg = JSON.parse(localStorage.getItem('cfg') || '{}');
doSomething(cfg.timeout.value); // cfg 形状根本没保证, 随时可能崩
// 正解:把返回值当 unknown, 立即用 schema 校验
const raw: unknown = JSON.parse(localStorage.getItem('cfg') || '{}');
const cfg = ConfigSchema.parse(raw); // 不合格立即抛错, 就地拦截
第二个是数组下标访问。默认情况下,const arr: string[] 里 arr[100] 的类型被推断成 string,可它运行时明明可能是 undefined!这是 TS 默认的一个"善意的谎言"。TypeScript 4.1 起提供了 noUncheckedIndexedAccess 编译选项,开启后,下标访问的结果类型会自动带上 undefined,逼你处理越界的可能。
// 开启 noUncheckedIndexedAccess 后:
const arr: string[] = ['a', 'b'];
const first = arr[0]; // 类型变成 string | undefined
console.log(first.length); // 编译报错: first 可能是 undefined
// 必须先判断, 杜绝"数组越界拿到 undefined 再点属性"的崩溃
if (first !== undefined) console.log(first.length);
这两个破洞的共同点,是它们都藏在 TypeScript "为了好用而默认放宽"的地方——JSON.parse 返回 any 是为了方便,数组下标不带 undefined 是为了少啰嗦。便利的背面就是风险:凡是 TS 默认替你"乐观假设"的地方,都是潜在的运行时崩溃点。把这些乐观假设一个个收紧(开严格选项、入口校验),你的"编译通过"才更接近"运行安全"。
写在最后
这次"编译全绿、线上白屏"的事故,纠正了我对 TypeScript 一个根深蒂固的误解:我曾把它当成一件能让我高枕无忧的"运行时护甲",以为穿上它,脏数据就伤不到我。可真相是,它更像一副编译期的"图纸校对工具"——它能在你画图纸时,挑出图纸内部自相矛盾的地方,却管不了施工时工地上真正运来的是不是图纸标的那批料。料对不对,得在料进场时有人验收,而不能指望那张图纸自己跳出来拦。
所以用好 TypeScript 的关键,不在于把类型标得多么花哨复杂,而在于想清楚一件事:哪里是我代码内部的世界(交给类型系统),哪里是与外部世界的边界(必须用运行时校验亲自把关)。as 和 any 之所以危险,正是因为它们让我们在边界上偷懒——用一句轻飘飘的断言,假装外部世界一定如我所愿。可外部世界从不承诺什么:接口会变、字段会缺、用户会乱填。真正的健壮,来自于你坦然承认这种不确定,并在边界上认真地接住它。愿你我都能记住这次白屏的教训:把类型当图纸,把校验当验收,让 TypeScript 守住它该守的,也别让它替我们守它守不住的。
如果你手上也有 TypeScript 项目,不妨今天就花二十分钟做三件小事自查。第一,全局搜一下 as 和 : any,把那些用在外部数据(接口响应、storage、用户输入)上的逐个揪出来——它们是最危险的一批,优先改成入口校验。第二,打开 tsconfig.json,确认 strict 是不是 true,顺便把 noUncheckedIndexedAccess 也加上,然后按编译器新冒出来的报错一个个补判空,每一条都是一个被提前消灭的潜在崩溃。第三,挑一两个最核心的接口,给它们的响应数据接上 zod 之类的运行时校验,先把最要命的几条数据链路守住。这三件事都不难,却可能在下一次后端字段变更时,让你收到的是一条清晰的校验报错,而不是用户发来的白屏截图。
说到底,TypeScript 给我们的从来不是"绝对的安全",而是"一套帮你思考边界的工具"。它最大的价值,是逼着你去想清楚:数据从哪来、到哪去、在哪一刻它才真正可信。用得好,它能把一大类低级错误消灭在你按下保存键的瞬间;可一旦你用 as 和 any 在边界上偷懒,就等于亲手把这套思考绕了过去,把风险一路推到线上。这次白屏给我最深的体会是:类型安全不是一个开关,而是一种持续的、对"数据是否可信"保持清醒的习惯。愿你我都能把这份清醒刻进日常,让"编译通过"这四个字,真正配得上我们对它的那份信任。
—— 别看了 · 2026