我一直以为 TypeScript 的类型能在运行时帮我挡住脏数据,直到一个接口返回了不符合类型的 JSON,我的类型注解形同虚设、程序当场崩溃的深度复盘
这是一个让我对"静态类型的边界"彻底清醒的故事。我用 TypeScript 写前端,享受着它带来的类型安全:每个变量、每个函数,都有类型;编译器帮我挡住了无数低级错误。我对它产生了一种近乎"信仰"的依赖。在调用后端接口时,我也很自然地,给返回的数据,标上了类型——比如,我有一个 User 接口类型,我请求用户数据时,就把 fetch 回来的 JSON,断言成 User 类型,然后心安理得地、按 User 的结构去使用它。在我的认知里,既然我标了它是 User,那 TypeScript 就该保证它真的是个合法的 User,我用起来就绝对安全。
可现实,给了我一记响亮的耳光。有一天,后端的某个接口,因为一个 bug,返回了一份不符合 User 类型的 JSON——比如,本该有的 profile 字段是 null,或者某个字段的类型变了。而我的代码,毫无防备地,按 User 的结构,去访问 user.profile.name——当场崩溃:Cannot read properties of null (reading 'name')。整个页面白屏。我当时第一反应是难以置信:我明明把它标成 User 类型了啊!TypeScript 的类型检查呢?它为什么没有在我访问一个不存在的字段时,报错、或者拦住我?我一度怀疑是不是我的类型定义写错了。直到我去深究 TypeScript 的编译产物,才彻底醒悟,补上了关于静态类型最重要的一课:TypeScript 的类型,是纯编译时(compile-time)的!它们,在代码被编译成 JavaScript 之后,会被完全地"擦除(type erasure)"掉——运行时的那份 JavaScript 里,根本不存在任何类型信息,也没有任何由 TypeScript 添加的运行时检查。我给 fetch 结果标的那个 User 类型,本质上,只是一个我对编译器单方面的"承诺/断言"——我在告诉编译器"相信我,这个数据就是 User",而编译器就真的信了,并在编译时,按 User 的结构来帮我做静态检查。可它从不会(也没法)在运行时,真的去核实那份从网络来的 JSON,到底是不是一个合法的 User!所以,当后端返回了脏数据,TypeScript 那个编译时的"类型保证"就成了一句空话——它挡不住任何来自外部世界的、不符合类型的真实数据。我那个 as User,不是一道校验的"防线",而是一句我对编译器说的、可能并不属实的"谎言"。
故障现场:一个 as User,挡不住真实的脏数据
我把这个"类型注解形同虚设"的现场,用代码摊开给你看:
// 类型定义
interface User {
id: number;
profile: { name: string; age: number };
}
// ✗ 灾难: 以为标了 User 类型, 运行时就安全了
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json(); // res.json() 的类型是 any!
return data as User; // ✗ as User: 只是"断言", 不是"校验"!
// ↑ 我在告诉编译器"相信我这是 User", 但运行时根本没检查!
}
// 使用方, 心安理得地按 User 结构用
const user = await getUser(1);
console.log(user.profile.name); // ✗ 如果后端返回的 profile 是 null...
// ↑ 运行时崩溃: Cannot read properties of null (reading 'name')
// 真相: 编译后的 JavaScript 里, 这些类型"全没了"(type erasure)
// 编译产物大致是:
// async function getUser(id) {
// const res = await fetch(`/api/users/${id}`);
// const data = await res.json();
// return data; // ← User、as User 全擦除了! 没有任何检查
// }
// console.log(user.profile.name); // ← 运行时就是普通 JS, profile 是啥就是啥
// 根因: TS 类型是"编译时"的, 运行时被完全擦除。
// "as User" 是给编译器的"断言/承诺", 不是"运行时校验"。
// 它管不了"从外部(网络/用户输入/文件)来的、真实的数据"长什么样。
看着这段代码和它编译后的样子,我才算真正理解了这个"类型注解形同虚设"的根源。问题的核心,是我对 TypeScript 类型,有一个根本性的误解:我以为它的类型,是一种运行时的保证——以为我标了 User,运行时这个数据就一定是合法的 User。可事实是,TypeScript 的类型,是纯粹编译时的概念。这要从 TypeScript 的工作方式说起:TypeScript 代码,最终是要被编译成 JavaScript 去运行的;而在这个编译过程中,所有的类型信息(类型注解、接口、as 断言),都会被完全地"擦除(type erasure)"掉——编译产物,是纯粹的 JavaScript,里面不含任何类型信息,也没有任何由 TypeScript 自动加上的运行时检查。这就意味着:我写的 return data as User,在编译后,仅仅变成了 return data——那个 as User,消失得无影无踪。它在编译时,只起了一个作用:告诉编译器"相信我,这个 data 就是 User",让编译器不再对它报类型错误。它本质上,是我对编译器的一个单方面的"断言/承诺",而不是一次"运行时的校验"。所以,致命的地方就在这里:当那份 JSON,是从网络(一个我无法控制的外部世界)来的、并且恰好不符合 User 的结构时,TypeScript 完全无能为力——它在编译时,只会按照我"承诺"的 User 结构,去检查我自己写的代码(确保我没把 user.id 当字符串用之类);但它从不会、也没法,在运行时,去真正核实那份真实的、动态的 JSON 数据,到底是不是一个合法的 User。于是,当后端返回了脏数据(profile 是 null),我的代码运行到 user.profile.name,就在纯粹的 JavaScript 世界里,实实在在地崩溃了。我那个 as User,我一直以为它是一道结结实实的"校验防线",可它其实,只是一句我对编译器说的、而后端并不会遵守的"谎言"——它挡得住我自己代码里的笔误,却挡不住任何一个来自外部世界的、不守规矩的真实数据。
第一件事:搞懂 TS 类型是编译时的,运行时被擦除
定位到根源,我必须把"TypeScript 类型是编译时的、运行时被擦除"这个本质,彻底搞清楚:
// TS 类型的本质: 编译时存在, 运行时被"擦除(type erasure)"
// 关键事实:
// 1. TS 类型(注解、interface、type、泛型、as)只在"编译时"存在,
// 用于"静态类型检查"(在你写代码/编译时, 帮你找错)。
// 2. 编译成 JS 后, 类型信息"全部消失"——运行时是纯 JS, 没有任何类型。
// 3. 所以, TS"不会"生成任何运行时的类型校验代码。
// 由此推出几个"反直觉但重要"的结论:
// 结论1: 类型注解 / as, 不是运行时校验
const data = JSON.parse(jsonStr) as User; // 运行时不检查! data 实际是啥就是啥
// 结论2: 不能用 interface 做 instanceof(接口运行时不存在!)
// if (x instanceof User) // ✗ 报错! interface 在运行时根本不存在
// 结论3: 不能在运行时"反射"出类型信息
// typeof 只能拿到 JS 的基础类型(string/number/object...), 拿不到 TS 的 User
// TS 类型能帮你的, 和不能帮你的:
// ✓ 能: 检查"你自己写的代码"内部的类型一致性(编译时)。
// (如把 number 当 string 用、访问不存在的属性、参数类型不对)
// ✗ 不能: 保证"从外部进来的运行时数据"符合你声明的类型。
// (网络响应、用户输入、localStorage、文件、JSON.parse 的结果...)
// 一句话: TS 类型, 是"编译时的契约", 不是"运行时的保镖"。
// 它信任你的"断言", 但不替你核实"外部世界的真实数据"。
// → 凡是"外部来的数据", 都要在运行时自己做校验!
原理终于刻进脑子里了。TypeScript 类型的本质,就一句话:它只在"编译时"存在,用于静态类型检查;一旦编译成 JavaScript,所有类型信息就被完全"擦除"了,运行时是纯粹的 JS,不含任何类型,也没有任何由 TS 生成的运行时校验代码。这个事实,推导出几个"反直觉、但极其重要"的结论:结论一,类型注解和 as 断言,都不是运行时校验——JSON.parse(s) as User,运行时压根不检查,data 实际是什么就是什么。结论二,不能用 interface 做 instanceof——因为接口在运行时根本不存在(x instanceof User 会直接报错)。结论三,不能在运行时"反射"出 TS 的类型信息——typeof 只能拿到 JS 的基础类型,拿不到你定义的 User。由此,就能清晰地划出 TypeScript 类型"能帮你什么、不能帮你什么"的边界:它能做的,是在编译时,检查"你自己写的代码"内部的类型一致性(比如你有没有把 number 当 string 用、有没有访问不存在的属性、传参类型对不对);它不能做的,是保证"从外部进来的、运行时的真实数据"符合你声明的类型——而"外部数据",恰恰包括了网络响应、用户输入、localStorage、文件内容、JSON.parse 的结果这些你无法控制其真实内容的东西。我把这个边界,浓缩成了一句话刻在心里:TypeScript 类型,是一份"编译时的契约",而不是一个"运行时的保镖"。它会信任你给出的"断言",但它不会替你去核实外部世界传来的真实数据。所以,凡是从外部边界进来的数据,都必须在运行时,由你自己,亲手去做校验——这,是我用一次白屏崩溃,给静态类型补上的、最关键的一课。
第二件事:正解——在边界处做运行时校验
搞懂了根因——"TS 类型运行时被擦除、不校验外部数据"——正解就清晰了:在"外部数据进入系统的边界处"(如接口响应、用户输入、读文件),做真正的运行时校验;最常用、最优雅的方式,是用 zod 这类"校验即类型"的库——你定义一个 schema,它既能在运行时校验数据是否合法,又能自动推导出 TS 类型,一举两得。
// 正解1(推荐): 用 zod 在边界做运行时校验 + 自动推导类型
import { z } from "zod";
// 定义 schema(它既是"运行时校验规则", 又能推出"TS 类型")
const UserSchema = z.object({
id: z.number(),
profile: z.object({
name: z.string(),
age: z.number(),
}),
});
type User = z.infer<typeof UserSchema>; // ✓ 类型从 schema 自动推导出来
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return UserSchema.parse(data); // ✓ 运行时真校验! 数据不合法会"抛错"
// ↑ 而不是 as User(那只是骗编译器)。
// parse 通过 → 你"确信"它是合法 User; 不通过 → 当场抛错, 在边界拦住脏数据。
}
// 正解2: 至少做手动校验(没引库时的兜底)
function isUser(x: any): x is User {
return x && typeof x.id === "number"
&& x.profile && typeof x.profile.name === "string";
}
const data = await res.json();
if (!isUser(data)) throw new Error("接口返回的数据不符合 User 结构");
// 此后 data 被收窄为 User, 用着才真安全。
// 正解3: 把"外部数据"先当成 unknown, 强制自己去校验/收窄
async function getRaw(): Promise<unknown> { // ✓ 返回 unknown, 不是 User!
return (await fetch("/api/x")).json();
}
// 调用方拿到 unknown, 被 TS"逼着"必须先校验, 才能当 User 用。
// 核心: 在"信任边界"上, 把外部数据当"不可信"的, 做运行时校验。
// 校验通过, 才把它当成那个类型来用——此时类型才名副其实。
这套正解,核心是一个重要的工程思想:在"外部数据进入系统的边界"上,做真正的运行时校验——把外部数据,默认当成"不可信"的,校验通过了,才把它当成那个类型来用。正解1(用 zod,最推荐):用 zod 这类库,定义一个 schema——它的精妙之处在于"校验即类型":这个 schema,既是一套运行时的校验规则(用 UserSchema.parse(data) 去校验,数据不合法就当场抛错),又能用 z.infer 自动推导出对应的 TS 类型,让你不必重复定义类型和校验逻辑;parse 通过了,你才能真正确信它是合法的 User;不通过,就在边界处当场拦住脏数据,而不是让它流进系统深处去引爆。正解2(手动校验,兜底):如果不想引库,至少要写一个类型守卫(type guard)函数(如 isUser),手动检查关键字段,校验不过就抛错——这样校验之后,TS 也能把数据收窄为 User,用着才真安全。正解3(把外部数据当 unknown):一个很好的习惯,是让获取外部数据的函数,返回类型标成 unknown(而不是 User)——这样,TS 就会逼着调用方,必须先做校验/收窄,才能把它当 User 用,从语言层面强制你别跳过校验。归根结底,这一切的核心,是建立"信任边界(trust boundary)"的意识:在外部数据进来的那道边界上,把它当成不可信的,做一次扎实的运行时校验;只有校验通过之后,那个 TS 类型,才名副其实地成立。我那次的错误,正是在边界上,用一个 as User 的"谎言",跳过了这道本该有的校验。
下面这张图,对比了"as 断言"和"运行时校验"两条路径:
这张图的对比很清楚:左边红色那条,用 as User 断言,只骗了编译器、运行时不做任何检查,脏数据畅通无阻地流进系统,直到在深处某个地方访问字段时才崩溃(且难定位);右边绿色那条,用 zod 等做运行时校验,在边界处真正核实数据,合法才放行、不合法当场抛错拦住。两条路的根本分野,在于你有没有在信任边界上,把外部数据真正校验一遍,而不是用一句断言把它"放行"。
第三件事:还有哪些"以为类型保证了运行时"的坑
填平了接口数据这个坑,我系统排查了一遍:还有哪些地方,我也错误地"以为 TS 类型保证了运行时安全":
// 其它"以为类型管了运行时、其实没管"的坑:
// 1. 接口/网络响应(本文): res.json() 是 any/unknown, as 不校验。
// 2. JSON.parse 的结果
const obj = JSON.parse(str); // 类型是 any! 你 as 成什么都行, 但运行时不验。
// 3. localStorage / sessionStorage 读出来的
const u = JSON.parse(localStorage.getItem("user")!) as User; // ✗ 存的可能是旧结构
// 4. 用户输入 / 表单 / URL 参数
const age: number = Number(input.value); // input 是字符串, 用户可能输入乱七八糟
// 5. 第三方库返回的、或 @types 声明不准的数据
// @types 是"人写的声明", 可能和库的"真实运行时行为"不一致 → 类型骗了你。
// 6. as any / 双重断言 as unknown as T —— 强行绕过类型, 风险自负
const x = someValue as unknown as User; // 彻底骗过编译器, 运行时毫无保障
// 7. 非空断言 ! —— 编译时说"它非空", 运行时它可能真是 null
const name = user!.profile!.name; // 运行时 user 是 null 照样崩
// 共同点: 凡是"数据来源在 TS 的掌控之外"(外部输入、any、断言),
// TS 的类型就只是"一厢情愿"。
// 原则: 区分"内部可信数据"(TS 能保证)和"外部不可信数据"(必须运行时校验)。
// 信任边界之外的一切, 都要验。
这一排查,让我对"类型管不了的地方"有了全面的警觉。除了接口响应,还有一大堆地方,我都曾错误地"以为 TS 类型保证了运行时安全",但其实它管不着:JSON.parse 的结果(类型是 any,你 as 成什么都行,运行时不验);localStorage 读出来的(存的可能是旧版本的结构);用户输入、表单、URL 参数(用户什么都可能输);第三方库 / @types 声明(@types 是人手写的声明,可能和库的真实运行时行为不一致,类型反而骗了你);as any / 双重断言 as unknown as T(强行绕过类型检查,运行时毫无保障);非空断言 !(编译时说"它非空",运行时它可能真是 null,照样崩)。这些坑的共同点是:凡是"数据来源在 TS 的掌控之外"(外部输入、any、各种断言),TS 的类型就只是一种"一厢情愿"。由此,我立下了一个清晰的原则:要区分"内部可信数据"(在你自己代码内部流转、TS 能保证类型的)和"外部不可信数据"(从系统边界外进来的);对前者,放心享受 TS 的类型安全;而对后者,凡是信任边界之外的一切,都必须做运行时校验。把这条信任边界划清楚,你才能既享受静态类型的便利,又不被它"管不了运行时"这个边界给反噬。
第四件事:厘清 TS 类型系统"能管"和"管不了"的边界
借着这次复盘,我把 TypeScript 类型系统"能管什么、管不了什么"的边界,系统地梳理了一遍——这是用好它、又不被它误导的前提:
// TS 类型系统的"能力边界":
// ✓ TS 能管的(编译时, 针对"你自己代码内部"):
// - 变量/参数/返回值类型是否匹配
// - 访问的属性/方法是否存在
// - 函数调用的参数个数和类型对不对
// - 联合类型的穷尽检查、空值检查(strictNullChecks)
// → 在你"写代码、编译"时, 把内部的类型错误找出来。
// ✗ TS 管不了的(运行时, 针对"外部进来的数据"):
// - 网络响应的真实结构(res.json() 给你 any)
// - 用户输入、表单、URL 参数的真实内容
// - JSON.parse / localStorage / 文件 读出来的东西
// - 第三方库真实的运行时行为(@types 可能不准)
// - 任何你用 as / as any / ! 强行"断言"的地方
// → 这些, TS 编译时看不到、运行时又被擦除, 它无能为力。
// 一个核心概念: "信任边界(Trust Boundary)"
// - 边界"内": 你自己写的、TS 能完整检查的代码 → 信任 TS 的类型。
// - 边界"外": 网络、用户、存储、第三方 → 不可信, 进来时必须运行时校验。
// - 校验, 就发生在这道边界上: 外部数据进来 → 校验 → 通过后才"获得"类型身份。
// 正确的心智模型:
// TS 类型 = "你和编译器之间的契约", 保证你代码内部自洽。
// 运行时校验 = "你和外部世界之间的海关", 检查进来的货物是否合规。
// 两者缺一不可: 光有契约(TS)挡不住走私(脏数据), 要在海关(边界)查验。
这一梳理,让我对 TypeScript 的能力,有了不再盲目、也不再苛求的清醒认识。TS 能管的(编译时、针对"你自己代码内部"):变量/参数/返回值的类型匹配、属性方法是否存在、函数调用参数对不对、联合类型的穷尽检查、空值检查——它在你写代码和编译时,帮你把内部的类型错误找出来。TS 管不了的(运行时、针对"外部进来的数据"):网络响应的真实结构、用户输入、JSON.parse/localStorage/文件读出来的东西、第三方库真实的运行时行为、以及任何你用 as/! 强行断言的地方——这些,它编译时看不到、运行时又被擦除,无能为力。而厘清这个边界,引出了一个核心概念——"信任边界(Trust Boundary)":边界"内",是你自己写的、TS 能完整检查的代码,你可以信任 TS 的类型;边界"外",是网络、用户、存储、第三方,它们不可信,进来时必须做运行时校验;而校验,就该发生在这道边界上——外部数据进来,先校验,通过之后,它才"获得"那个类型身份。我最喜欢的一个心智模型是:TS 类型,是"你和编译器之间的契约",保证你代码内部自洽;而运行时校验,是"你和外部世界之间的海关",检查进来的货物是否合规。这两者,缺一不可:光有契约(TS),挡不住走私(脏数据);你必须在海关(边界)上,亲自查验。把 TS"能管"和"管不了"的,整理成一张对照表:
| 场景 | TS 能管吗 | 怎么办 |
|---|---|---|
| 自己代码内部类型一致 | 能(编译时) | 信任 TS |
| 访问不存在的属性 | 能 | 信任 TS |
| 接口/网络响应数据 | 管不了 | 运行时校验(zod) |
| 用户输入/表单 | 管不了 | 运行时校验 |
| JSON.parse/localStorage | 管不了 | 运行时校验 |
| as / as any / ! 断言处 | 被你绕过了 | 慎用,改真校验 |
第五件事:静态类型只防"自己",不防"外部世界"
这次踩坑,在认知层面给了我最大的纠偏——它让我看清了静态类型"安全感"的真实边界。我把这层反思,沉淀了下来:
认知纠偏: 静态类型的"安全感", 只覆盖"你自己的代码"
# 我的误解(错误的):
# "我用了 TypeScript, 一切都类型安全了。" —— 我把 TS 的安全感,
# 错误地, 扩大到了"它根本管不着的外部数据"上。
# 真相: 静态类型, 只防"你自己写的代码", 不防"外部世界"
# - 它能保证: 你代码内部, 类型自洽(不把 number 当 string)。
# - 它不保证: 从外部流进来的数据, 真的符合你声明的类型。
# → 它是"内部的质检", 不是"对外的海关"。
# 这是一个普遍的道理: 任何"防护", 都有它的作用范围
# - 类型检查: 防的是"代码内部的类型错误", 不防"运行时的脏数据"。
# - 单元测试: 测的是"你想到的 case", 不测"你没想到的"。
# - 编译通过: 证明"语法/类型对", 不证明"逻辑对、数据对"。
# → 别把一种防护的"安全感", 错误地扩大到它覆盖不到的地方。
# 尤其要警惕"系统的边界":
# bug 和攻击, 最常发生在"系统的边界"上——外部数据进入的地方。
# (注入攻击、脏数据崩溃、类型不符...都在边界)
# → "边界处, 对一切外部输入保持不信任、做校验", 是健壮系统的基本功。
# 正确的习惯:
# 1. 清楚每种"防护"(类型/测试/编译)的作用范围, 别过度信任。
# 2. 识别系统的"信任边界", 在边界处对外部数据做运行时校验。
# 3. 对"外部世界"保持一份健康的"不信任"——它不会按你的类型来。
核心: 静态类型只防自己, 不防外部世界。把外部数据当"不可信", 在边界校验——
这是静态类型给不了、却必须由你补上的那道防线。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是我把 TypeScript 的"安全感",错误地扩大了——"我用了 TypeScript,一切都类型安全了"——我把这份安全感,延伸到了它根本管不着的"外部数据"身上。可真相是:静态类型,只防"你自己写的代码",不防"外部世界"。它能保证你代码内部的类型自洽(不把 number 当 string 用),但不能保证从外部流进来的数据,真的符合你声明的类型——它是一道"内部的质检",而不是一道"对外的海关"。而这,是一个更普遍的道理:任何一种"防护",都有它明确的作用范围——类型检查,防的是代码内部的类型错误,不防运行时的脏数据;单元测试,测的是你想到的 case,不测你没想到的;编译通过,只证明语法和类型对,不证明逻辑对、数据对。千万别把一种防护的"安全感",错误地扩大到它覆盖不到的地方——那种虚假的安全感,本身就是最危险的。而尤其要警惕的,是"系统的边界":大量的 bug 和安全问题,最常发生在"系统的边界"上——也就是外部数据进入系统的地方(脏数据导致的崩溃、各种注入、类型不符,几乎都在边界爆发)。所以,"在边界处,对一切外部输入保持不信任、做校验",是构建一个健壮系统的基本功。由此,我立下了几条习惯:第一,清楚每一种防护(类型、测试、编译)的作用范围,别过度信任它;第二,主动识别系统的"信任边界",并在边界处,对外部数据做运行时校验;第三,对"外部世界",永远保持一份健康的"不信任"——因为它,根本不会按照你的类型定义来。归根结底:静态类型,只防自己,不防外部世界。把外部数据当成"不可信"的、在边界上校验它——这,正是静态类型给不了你、却必须由你亲手补上的、那道最关键的防线。把"过度信任类型"和"边界处校验"两种心态对比成一张表:
| 维度 | 过度信任类型(踩坑) | 边界处校验(稳) |
|---|---|---|
| 对 TS 的认知 | 用了就全安全 | 只保内部代码自洽 |
| 对外部数据 | as 一下就当可信 | 当不可信,运行时校验 |
| 防护范围 | 无限放大安全感 | 清楚每种防护的边界 |
| 关注点 | 只顾内部 | 盯紧系统的信任边界 |
| 脏数据 | 流进深处才崩 | 边界处当场拦截 |
一套"这个数据要不要运行时校验"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"拿到一个数据、该不该做运行时校验"的决策图,贴在了团队的 TS 规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:拿到一个数据,先问它从哪来——如果是我自己代码内部产生和传递的,信任 TS 类型、无需运行时校验;如果是从外部世界(网络、用户、存储、第三方)来的,就把它当不可信的,在边界处做运行时校验(推荐 zod 这类 schema 校验 + 推导类型,轻量场景手写类型守卫);校验通过了,才把它当成那个类型安全使用,不通过就当场抛错或降级、在边界拦住。这条以"数据来源"划分信任、并在边界处校验的决策链,现在是我们团队处理每一个数据时的准则。
我立下的几条 TypeScript 类型规矩
这次"类型注解形同虚设"的踩坑,让我把使用 TypeScript 的注意事项,认真地立成了几条规矩:
- 记牢 TS 类型是编译时的,运行时被擦除。类型注解、
as断言都不会生成运行时校验,接口运行时也不存在。 - 外部数据必须运行时校验。网络响应、用户输入、
JSON.parse、localStorage、第三方数据——一律在边界校验。 - 优先用 zod 等 schema 库。校验即类型,一处定义既校验运行时、又推导出 TS 类型。
- 外部数据先当
unknown。让 TS 逼着你先校验再使用,别直接as成目标类型。 - 慎用
as/as any/!。它们是绕过类型检查的"谎言",不是校验,运行时毫无保障。 - 识别系统的"信任边界"。边界内信任 TS,边界外一切不可信、要查验。
- 别把一种防护的安全感无限放大。类型、测试、编译各有作用范围;静态类型只防自己,不防外部世界。
写在最后
这次"我以为 TypeScript 的类型能挡住脏数据、结果一个接口就让程序当场崩溃"的经历,是我在前端工程路上,一次很打脸、却也很受用的成长。它教给我的,远不止"外部数据要运行时校验"这一条具体的技术经验,更是一种对"防护手段作用边界"的清醒认知——任何防护,都有它的作用范围,而最危险的,恰恰是把一种防护的安全感,错误地、无限地,扩大到它根本覆盖不到的地方。TypeScript 给了我代码内部的类型安全,我却天真地以为,它能替我挡住整个外部世界的脏数据——而它,从一开始,就只是一份编译时的契约,不是运行时的保镖。
所以,当你享受着某种工具带来的安全感时——无论是静态类型、单元测试、还是编译通过——请别让这份安全感,麻痹了你对它作用边界的判断;尤其要警惕系统的"信任边界":对一切从外部世界流进来的数据,都保持一份健康的"不信任",在它进来的那一刻,亲手校验它。就像 TypeScript,你只要清醒地知道"它只管编译时、管不了运行时的外部数据",就一定会在接口、输入这些边界上,补上一道运行时校验,绝不会经历我那种"类型标得好好的、却被一份脏 JSON 打到白屏"的崩溃。清楚每一种防护的边界、并在系统的信任边界上,亲手筑起对外部世界的校验防线,是从一个"会用类型"的开发,走向一个"能写出健壮、可信赖系统"的工程师,必经的修炼。愿你享受静态类型的便利,也清醒它的边界;愿你对外部世界,永远保持一份审慎的不信任,在边界处,守好那道校验的关。共勉。
—— 别看了 · 2026