本节目标:把「类型擦除」从一个口号变成一份可核对的清单。读完你能准确说出任意一段 TypeScript 编译成 JavaScript 后会剩下什么,知道
enum、namespace、参数属性为什么「擦不干净」,并理解运行时校验为什么不可省略。
8.2 类型擦除后的运行时形态
上一节我们看到 V8 只认运行时形状,不认类型标注。现在把镜头拉近一点:TypeScript 编译器到底把 .ts 变成了什么样的 .js?答案不是「删掉所有类型」这么简单——擦除是有例外的,而例外恰好是运行时 bug 与性能问题的温床。
1.2 类型擦除与运行时边界 已经确立了「类型不产生运行时代码」这条总原则,本节把它落到具体语法上逐条验证。
8.2.1 完全消失的东西
绝大多数类型语法在产物里一个字节都不剩,它们只是给编译器看的注释。
| 语法 | 产物 |
|---|---|
类型标注 x: number | 删除,只剩 x |
接口 interface Foo {} | 完全删除 |
类型别名 type Id = string | 完全删除 |
泛型参数 <T> | 完全删除 |
类型断言 x as Foo | 删除断言,保留 x |
非空断言 x! | 删除 !,保留 x |
satisfies 表达式 | 删除,保留被检查的值 |
declare 声明 | 完全删除(本就无实现) |
abstract 修饰符 | 删除,类是普通类 |
private / public / readonly | 删除修饰符 |
验证方式很简单,写一个只有类型、没有值的文件:
interface User {
id: string;
name: string;
}
export function getName(user: User): string {
return user.name;
}
产物只剩下一半:
export function getName(user) {
return user.name;
}
由此得到一条关键推论:类型不能作为值使用。
import type { Config } from "./config";
interface User {
id: string;
}
const u = new User(); // 报错 TS2693
const c = Config; // 报错 TS1361
error TS2693: 'User' only refers to a type, but is being used as a value here.
error TS1361: 'Config' cannot be used as a value because it was imported using 'import type'.
import type 的价值不只是语义清晰,它还让打包器可以安全地删除整条导入链。对循环依赖尤其重要,这一点会在 9.3 循环依赖与类型-only 导入
展开。
8.2.2 留下运行时代码的三类语法
现在进入重点。有三类语法「擦不干净」,会真的产出可执行代码。
枚举(enum)
数字枚举编译成一段自执行函数,并生成反向映射:
enum Color {
Red,
Green,
Blue,
}
var Color;
(function (Color) {
Color[(Color["Red"] = 0)] = "Red";
Color[(Color["Green"] = 1)] = "Green";
Color[(Color["Blue"] = 2)] = "Blue";
})(Color || (Color = {}));
于是运行时两个方向都能查:Color.Red 是 0,Color[0] 是 "Red"。字符串枚举不生成反向映射,产物就是一个普通对象。
const enum 是另一套逻辑:它在编译期被内联,产物里连对象都不存在。
const enum Dir {
Up = 1,
Down = 2,
}
const d = Dir.Up; // 编译后:const d = 1 /* Dir.Up */
内联很优雅,代价是与「单文件编译」模型冲突——打包器单独处理一个文件时看不到定义,无法内联。这正是 isolatedModules 存在的理由,开启后使用 const enum 直接报错:
error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.
现代工程里 isolatedModules 与 verbatimModuleSyntax 几乎默认开启,因为 esbuild、swc、Babel 都只能逐文件转译。想了解这类转译器为何必须逐文件工作,可以延伸阅读 esbuild 原理
。
命名空间(namespace)
非 declare 的命名空间同样编译成 IIFE:
namespace Utils {
export function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
}
产物与枚举同构:一个 var Utils 加一段 IIFE,函数被挂成 Utils.clamp 属性。结论很清晰——namespace 不是「类型层面的组织工具」,它真的会生成对象和属性赋值。ESM 已普及的今天,新代码应优先用模块。
参数属性(parameter properties)
构造函数参数上的修饰符是「声明 + 赋值」的语法糖:
class User {
constructor(
private readonly id: string,
public name: string,
) {}
}
class User {
constructor(id, name) {
this.id = id;
this.name = name;
}
}
readonly 完全消失,private 也完全消失——运行时谁都能改 user.id。这是最容易被误解的一点:private 是编译期约定,不是运行时保护。
8.2.3 private 与 #private 的本质差别
既然 private 只是约定,真正的私有需要语言级支持:
class Account {
private balance = 0; // 编译期私有
#pin = "0000"; // 运行时私有
withdraw(n: number): void {
this.balance -= n;
}
}
差别体现在运行时行为上:
| 维度 | private balance | #pin |
|---|---|---|
JSON.stringify | 会序列化出来 | 不会 |
Object.keys | 可见 | 不可见 |
| 外部赋值 | 允许(无保护) | 抛 TypeError |
| 反射 / 调试 | 完全可见 | 引擎层屏蔽 |
外部强行访问会直接抛错:
const a = new Account();
a.#pin; // SyntaxError: Private field '#pin' must be declared in an enclosing class
所以「需要真正不可篡改的字段」时,# 才是答案。序列化敏感数据(密码、令牌)尤其应该用 #,否则一次 JSON.stringify(user) 就把它们泄出去了。
8.2.4 类字段的语义分水岭:useDefineForClassFields
类字段有一个影响语义的开关。
class Derived extends Base {
value = 2;
}
当 useDefineForClassFields: false(旧行为)时,字段被编译成构造期赋值:
class Derived extends Base {
constructor() {
super(...arguments);
this.value = 2; // 赋值语义
}
}
当它为 true(target: ES2022 起的默认值)时,字段走 [[Define]] 语义,等价于 Object.defineProperty,且不经过构造期赋值。
差别在继承访问器时暴露:
class Base {
get value(): number {
return 42;
}
}
class Derived extends Base {
value = 1; // 会覆盖访问器吗?
}
false:this.value = 1会触发 setter;若只有 getter,严格模式下抛错。true:直接定义自有属性,静默遮蔽基类 getter。
TypeError: Cannot set property value of #<Base> which has only a getter
这条报错只在 false 语义下出现。切到 ES2022 目标时它会消失,取而代之的是「值被悄悄改写」。排查这类问题的第一步,永远是确认 target 与 useDefineForClassFields 的实际取值。
8.2.5 装饰器与元数据:唯一「主动留痕」的机制
标准装饰器(TS 5.0+)本身只是函数调用,配合元数据才会留下类型信息。
function logged<T extends (...args: never[]) => unknown>(fn: T): T {
return function (this: unknown, ...args: never[]) {
console.log("call");
return fn.apply(this, args);
} as T;
}
class Service {
@logged
run(): void {}
}
编译后 @logged 变成一次装饰器调用,logged 函数本身完整保留在产物里。更值得关注的是元数据:开启 emitDecoratorMetadata(旧式)或使用 TS 5.2+ 的 Symbol.metadata,类型信息会以构造函数引用的形式写进运行时。
UserService = __decorate(
[Injectable(), __metadata("design:paramtypes", [UserRepository])],
UserService,
);
这就是 reflect-metadata 与依赖注入容器能工作的全部秘密——它依赖编译器主动把类型写进产物,是擦除规则的一个受控例外。想了解这套机制如何被容器消费,可以延伸阅读 4.2 reflect-metadata 与依赖注入容器
与 4.1 标准装饰器(TS 5.x)
。
代价也要说清楚:元数据会显著增大产物体积,让类型引用变成运行时模块依赖(原本能用 import type 断开的环会被重新连上),也是启动期 undefined 的高发来源。
8.2.6 把类型装回去:运行时校验
既然类型在运行时不存在,来自外部的数据(HTTP 响应、配置、消息队列)就没有任何保护。看这段「类型正确」的代码:
interface ApiUser {
id: string;
age: number;
}
async function load(): Promise<ApiUser> {
const res = await fetch("/api/user");
return (await res.json()) as ApiUser; // 断言!不是校验
}
as ApiUser 只是让编译器闭嘴。若服务端返回 { "age": "30" },user.age + 1 会得到 "301"——类型系统完全失守。这正是 10.1 类型守卫与验证库原理
要解决的问题。
手写类型守卫适合字段少、依赖少的场景:
function isApiUser(v: unknown): v is ApiUser {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.id === "string" && typeof o.age === "number";
}
const data: unknown = await res.json();
if (!isApiUser(data)) throw new Error("invalid payload");
data.age; // 这里才真正安全
Schema 库适合结构复杂、需要精细错误信息的场景,它「用值描述类型」,让同一份定义同时提供静态类型与运行时校验:
import { z } from "zod";
const ApiUserSchema = z.object({
id: z.string().uuid(),
age: z.number().int().min(0),
});
type ApiUser = z.infer<typeof ApiUserSchema>; // 从 schema 反推类型
const parsed = ApiUserSchema.parse(await res.json()); // 运行时真校验
关键认知是:z.infer 让类型不再是「手写的假设」,而是校验逻辑的投影,两者永远不会漂移。想对比不同校验库的实现,可以延伸阅读 TypeScript 与 Zod 校验
和 Node.js 校验与 Zod
。
8.2.7 常见坑与错误信息
| 现象 | 根因 | 处理 |
|---|---|---|
TS2748 const enum 报错 | isolatedModules 下无法内联 | 改用普通 enum 或 as const |
TS1205 重导出类型报错 | 单文件转译无法判断是否类型 | 写 export type { Foo } |
TS2693 类型当值用 | 接口 / 别名无运行时实体 | 改用 class 或工厂函数 |
TS1361 import type 当值用 | 导入被擦除 | 改回普通 import |
| 敏感字段被序列化 | private 只是编译期约定 | 改用 #private |
| 元数据导致循环依赖 | design:paramtypes 引入运行时引用 | 拆模块或手动注册 |
关于 TS1205,verbatimModuleSyntax 会把问题提前暴露:凡是用 import / export 携带类型的写法都必须显式写成 import type / export type,编译器意图与打包器完全对齐。
还有一条容易被忽略:as const 是值层面的,不是类型层面的。
const Dirs = ["up", "down"] as const; // 产物是真实的数组
type Dir = (typeof Dirs)[number]; // "up" | "down",纯类型
Dirs 存在于运行时,Dir 不存在。用 as const 对象替代 enum 是当下最主流的做法,既保留运行时的值,又避开 const enum 的内联问题。
8.2.8 本节要点
- 类型标注、接口、别名、泛型、断言、修饰符——全部零产物。
- 三类语法会生成运行时代码:
enum、namespace、参数属性。 const enum靠内联,与isolatedModules冲突;新代码优先as const对象。private是编译期约定,#private才是运行时私有;敏感字段必须用后者。useDefineForClassFields决定类字段是赋值还是定义,切换target会改变语义。- 装饰器元数据是唯一「主动留痕」的类型信息,也是循环依赖的高发地。
- 外部数据一律先校验:类型守卫或 schema 库,都比
as断言可靠。
小结
本节我们把「类型擦除」拆成了一份可核对的清单:哪些语法彻底消失,哪些留下 IIFE、对象与属性赋值,哪些会主动把类型写回运行时。你也看到了擦除带来的三个真实后果——private 没有运行时保护、外部数据没有校验、元数据会重建模块依赖。
理解产物形态之后,下一步自然是测量产物在真实负载下的表现:哪个函数吃掉了 80% 的 CPU?内存为什么缓慢增长?这些问题靠读代码猜不出来,必须靠剖析器。下一节 8.3 性能剖析与火焰图 就来解决「先测量,再优化」这件事。
阅读导航:上一节:8.1 V8 类型反馈与 JIT · 下一节:8.3 性能剖析与火焰图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。