本节目标:读完这一节,你能说清「类型擦除」到底擦掉了什么、又留下了什么;能列出真实项目里最容易出事的几类运行时盲区,并把它们和具体的报错信息对上号;能在自己的代码里画出「信任边界」,判断哪一处数据必须校验、哪一处可以放心依赖类型。本节是第 13 章的开篇,负责提出问题;接下来的两节负责给出解法。
13.1 类型擦除带来的运行时盲区
前面十二章,我们一直在训练一件能力:让类型系统替我们提前发现错误。从原始类型到泛型,从判别联合到条件类型,所有的努力都指向同一个目标——把错误从运行时提前到编译时。
但这一切有一个前提,而这个前提经常被忽略:TypeScript 的类型只存在于编译期。tsc 做的事情,本质上就是把类型语法剥掉,输出一份普通的 JavaScript。运行时没有任何东西记得你写过 interface User。
于是出现了一个危险的落差:
- 编译器说「这段代码没问题」,它检查的是你声称的类型;
- 运行时真正跑的是数据的实际形状。
两者一致时万事大吉;不一致时,tsc 一声不吭,程序在半夜两点崩溃。
擦除到底擦掉了什么
先看一份最小的对照。左边是源码,右边是 tsc 的产物(target: ES2020):
// 源码:user.ts
interface User {
id: number;
name: string;
}
type Id = User["id"];
function greet(user: User, suffix?: string): string {
return `Hello, ${user.name}${suffix ?? ""}`;
}
// 产物:user.js
"use strict";
function greet(user, suffix) {
return `Hello, ${user.name}${suffix ?? ""}`;
}
interface 不见了,type 不见了,参数注解不见了,返回类型注解不见了,可选参数的 ? 变成了「运行时可能是 undefined」这一事实——而编译器对此毫不知情。函数签名从 (user: User, suffix?: string) => string 退化成 (user, suffix) => string,任何对象都能传进来。
这就是「类型擦除(type erasure)」:类型是给编译器看的注释,不是运行时的守卫。
哪些语法在运行时留下了痕迹
擦除并非百分百彻底。少数语法会生成真实的 JavaScript 代码,理解这份差异能帮你判断「哪里还有一点运行时保障」:
| 语法 | 是否生成运行时代码 | 说明 |
|---|---|---|
interface / type | 否 | 完全消失 |
类型注解、类型断言 as | 否 | 完全消失 |
import type / export type | 否 | 整条 import 被删除 |
enum | 是 | 生成一个双向映射对象 |
const enum | 默认内联 | 成员被替换成字面量,除非开 preserveConstEnums |
namespace | 是 | 生成 IIFE 与对象 |
| 类 | 是 | 类本身是值,但字段类型注解消失 |
参数属性 constructor(private x) | 是 | 生成赋值语句,类型消失 |
| 装饰器 | 是 | 生成装饰器调用;元数据类型需 emitDecoratorMetadata |
import type 值得单独强调。它不只是「更明确」,它决定了编译产物里根本没有这条 import:
// 只用来做类型标注 —— 必须写 import type
import type { User } from "./types";
// 既做类型又做值 —— 普通 import
import { UserSchema } from "./schemas";
如果误把纯类型导入写成普通 import,在 verbatimModuleSyntax 关闭时通常无碍,但在 ESM + 打包器组合下可能留下一条指向不存在导出的 import,运行时直接报错。这属于 11.1 ES 模块与模块解析
讨论的范围。
盲区一:as 断言把「我希望」写成了「它是」
最经典的场景是解析外部数据:
interface User {
id: number;
name: string;
email: string;
}
const raw = '{"id":1,"name":"Ada"}'; // 后端这次少给了 email
const user = JSON.parse(raw) as User;
console.log(user.email.toUpperCase());
// 运行时:TypeError: Cannot read properties of undefined (reading 'toUpperCase')
tsc 全程零报错。因为 JSON.parse 的签名是 (text: string) => any,而 any 可以断言成任何类型。as User 并没有检查任何东西,它只是让编译器闭嘴。
关键认知:as 不是转换,是承诺。你向编译器承诺「这里一定是 User」,编译器就不再追问。承诺错了,代价由运行时承担。
顺带一提,JSON.parse 的 any 返回值与 as 的组合,是 3.3 any·unknown·never·void 与类型断言
里强调过的最大风险点。
盲区二:索引访问的「假非空」
数组越界在 TypeScript 里是无声的:
const items = ["a", "b"];
const third = items[2]; // 类型被推断为 string
third.toUpperCase(); // 编译通过
// 运行时:TypeError: Cannot read properties of undefined (reading 'toUpperCase')
编译器默认认为「你访问了就一定存在」,因为 noUncheckedIndexedAccess 默认是关闭的。打开它,类型会诚实地变成 string | undefined:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
开启后上面的代码立刻变成编译错误:
'third' is possibly 'undefined'. ts(18048)
同样的坑出现在 Record 与索引签名上:
type Dict = Record<string, number>;
const d: Dict = { a: 1 };
const v = d["missing"]; // 类型 number,实际 undefined
console.log(v.toFixed(2)); // 运行时 TypeError
注意:strict: true 并不包含 noUncheckedIndexedAccess。这是新手最常见的误解之一。
盲区三:Object.keys 返回的不是 keyof
interface Config {
host: string;
port: number;
}
const cfg: Config = { host: "localhost", port: 8080 };
const keys = Object.keys(cfg); // 类型:string[]
keys.forEach((k) => console.log(cfg[k]));
// 编译报错:Element implicitly has an 'any' type because expression of type
// 'string' can't be used to index type 'Config'. ts(7053)
Object.keys 的返回类型被故意设计成 string[] 而不是 (keyof T)[],因为在结构化类型的世界里,对象完全可能比它声明的类型多出几个属性(下一节就会讲到)。要遍历键,需要显式收窄:
const keys = Object.keys(cfg) as (keyof Config)[];
keys.forEach((k) => console.log(cfg[k])); // 通过
或者写一个泛型辅助函数,把断言集中在一处:
function typedKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
盲区四:结构化类型的「多余属性」
TypeScript 是结构化类型系统:只要形状兼容,就认为可赋值。这意味着运行时对象可以比类型声明的多出字段:
interface Point {
x: number;
y: number;
}
const raw = { x: 1, y: 2, z: 3 };
const p: Point = raw; // 合法:变量赋值不做多余属性检查
console.log(Object.keys(p)); // [ 'x', 'y', 'z' ]
console.log(JSON.stringify(p)); // {"x":1,"y":2,"z":3}
z 被静默带进了后续所有环节。如果这个对象最终被写进数据库或者发给下游,多出来的字段就成了一次数据泄漏。
只有在对象字面量直接赋值时,编译器才会做多余属性检查(excess property check):
const q: Point = { x: 1, y: 2, z: 3 };
// 编译报错:Object literal may only specify known properties,
// and 'z' does not exist in type 'Point'. ts(2353)
一旦经过变量中转,这层保护就消失了。相关取舍在 5.3 结构化类型与两者取舍 中有更完整的讨论。
盲区五:非空断言与双重断言
function findUser(id: number): User | undefined {
// ...
return undefined;
}
const u = findUser(1)!; // 用 ! 抹掉 undefined
console.log(u.name);
// 运行时:TypeError: Cannot read properties of undefined (reading 'name')
! 和 as 是同一类东西:对编译器的承诺。它们的共同特点是——写起来只需一个字符,出事时要花两小时排查。
更糟的是双重断言,它能把两个毫无关系的类型连起来:
const s = "hello" as unknown as number; // 编译通过
s.toFixed(2); // 运行时:TypeError: s.toFixed is not a function
盲区六:异步间隙里的收窄失效
类型收窄(narrowing)是编译期的推理,它无法约束「两个语句之间世界发生了什么」:
interface State {
user?: { name: string };
}
const state: State = {};
if (state.user) {
const name = state.user.name; // 此处收窄成立
setTimeout(() => {
console.log(name.toUpperCase()); // 安全:name 是字符串的拷贝
}, 0);
}
上面这段是安全的,因为 name 拷贝了一个字符串。危险的是把整个对象带进异步回调:
if (state.user) {
setTimeout(() => {
console.log(state.user.name.length); // 收窄已失效,可能抛错
}, 0);
}
对象是引用,回调执行时 state.user 完全可能已经被别处清空。TypeScript 对 let 变量和属性访问在闭包内会重置收窄,但这依赖它能否静态判断——通过函数参数传出去、经过 any 中转、或者被 await 隔断之后,它就无能为力了。
盲区七:类型与运行时格式不一致
这一类最隐蔽:类型本身没错,错的是「JSON 里的表示」和「类型声称的表示」不一致。
interface Order {
id: number;
amount: number;
createdAt: Date; // ← 危险
}
const res = await fetch("/api/orders/1");
const order = (await res.json()) as Order;
console.log(order.createdAt.getTime());
// 运行时:TypeError: order.createdAt.getTime is not a function
// 因为 JSON 里 createdAt 是字符串 "2026-09-13T10:00:00+08:00"
Date 在 JSON 里永远是字符串。number 也可能是 "1"。这类错误在类型层面完全看不见,因为 as Order 已经把一切问题挡在了门外。
把报错对上号
上面这些盲区,落到运行时会变成几种固定的报错。熟悉它们能显著缩短排查时间:
| 报错信息 | 常见成因 |
|---|---|
Cannot read properties of undefined (reading 'x') | as 断言、!、越界索引 |
Cannot read properties of null (reading 'x') | 上游返回 null,类型写成了对象 |
x is not iterable | 字段缺失,for...of 拿到 undefined |
x.toUpperCase is not a function | 类型写成 string,实际是数字或对象 |
Unexpected token < in JSON at position 0 | 请求返回了 HTML 错误页,却直接 JSON.parse |
Converting circular structure to JSON | 结构化类型带来的意外字段形成环 |
其中 Unexpected token < 值得单独记住:< 是 HTML 的开头。它几乎总意味着「网关或鉴权中间件返回了错误页面,而你的代码以为拿到的是 JSON」。
画一张信任边界图
既然类型靠不住,那就得换一个思路:不追问「编译器信不信」,而追问「这份数据从哪来」。
数据从可信代码流到可信代码,类型足够;数据从外部跨进你的进程,就必须在跨进来的那一刻校验。
| 边界 | 数据来源 | 可信? | 应做的动作 |
|---|---|---|---|
| HTTP 响应体 | 第三方或后端服务 | 否 | schema 校验后使用 |
| HTTP 请求体 | 客户端 | 否 | 入口处 schema 校验 |
process.env | 部署环境 | 否 | 启动期校验,失败即退出 |
| JSON / YAML 配置文件 | 运维、其他团队 | 否 | 启动期校验 |
localStorage / Cookie | 用户可随意修改 | 否 | 读取时校验 |
| 消息队列 / 事件总线 | 其他服务 | 否 | 消费端校验 |
| 数据库返回值 | 你控制的库表 | 基本可信 | ORM 层类型即可 |
| 同模块内函数参数 | 自己的代码 | 是 | 依赖类型 |
这张表是本章的地图。第 13.2 节解决「用什么工具校验」,第 13.3 节解决「把校验放在哪几处」。
一条原则:Parse, don’t validate
传统写法是「先断言,用到时再检查」:
// ❌ 检查散落在各处,很容易漏
const user = JSON.parse(raw) as User;
if (!user.email) {
throw new Error("email 缺失");
}
更好的写法是「进门时就解析成一个可信类型」:
// ✅ 解析一次,之后全程可信
const user: User = parseUser(raw); // 解析失败直接抛错,成功则保证形状正确
区别在于:前者让「不可信数据」在系统里流动,每个使用点都要自己防御;后者在边界处把不可信数据转换成可信数据,类型从此不再是承诺,而是已经兑现的事实。
要写出 parseUser,我们需要一个能在运行时真正检查数据形状的工具。这正是下一节的主角。
小结
- 类型擦除意味着
tsc只做语法层面的剥离,运行时不保留任何类型信息;编译通过只代表「你声称的类型自洽」,不代表「数据真的是那个形状」。 interface、类型注解、as断言、import type都会被完全删除;只有enum、namespace、类、装饰器等少数语法留下运行时代码。- 七类高频盲区:
as断言外部数据、越界索引、Object.keys的string[]、结构化类型的多余属性、!非空断言、异步间隙中的收窄失效、类型与 JSON 表示不一致。 strict: true不包含noUncheckedIndexedAccess,索引访问的「假非空」需要单独开启。- 判断是否需要校验的方法不是看类型,而是看数据来源:跨进程边界的输入一律不可信,应当在边界处一次解析干净(Parse, don’t validate)。
下一节我们引入 Zod,把上面这张信任边界表里「应做的动作」真正落地:用一个 schema 同时描述运行时的检查规则和编译期的类型,让两者不再各写一份。
阅读导航:上一节:12.3 模块扩充与全局类型增强 · 下一节:13.2 Zod 模式验证与类型推导 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。