类型擦除:编译期与运行时的分界

2026-09-09 00:00    #typescript  

TypeScript 的所有类型在运行时都不存在。这不是一个"实现细节",而是理解整个 TypeScript 的起点:它一次性解释清楚了为什么 enum 会生成 IIFE、为什么 instanceof 不能用 interface、为什么 Node 原生跑不了 enum、为什么装饰器需要 experimentalDecorators。

编译期与运行时是两套世界

TypeScript 编译器(tsc)做三件事,顺序固定:

11. TypeScript 源码  →  TypeScript AST
22. 类型检查器检查 AST          ← 类型只在这一步存在
33. TypeScript AST   →  JavaScript 源码

第 2 步之后,类型信息就被丢弃了。第 3 步产出的 JavaScript 里不含任何类型。

用一段代码验证。content/typescript/src/type-erasure/erased.ts:

 1// 这些类型注解在编译后完全消失
 2const n: number = 1;
 3const s: string = "hello";
 4
 5interface User {
 6  name: string;
 7  age: number;
 8}
 9
10type ID = string | number;
11
12function greet(user: User): string {
13  return `hi ${user.name}`;
14}
15
16console.log(greet({ name: "rainboy", age: 18 }), n, s);

运行结果:

1hi rainboy 1 hello

编译产物(tsc --target esnext --module nodenext):

1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3// 这些类型注解在编译后完全消失
4const n = 1;
5const s = "hello";
6function greet(user) {
7    return `hi ${user.name}`;
8}
9console.log(greet({ name: "rainboy", age: 18 }), n, s);

对照一下丢掉了什么:

源码产物说明
const n: number = 1const n = 1类型注解被删除
const s: string = "hello"const s = "hello"类型注解被删除
interface User { ... }无整个声明消失
type ID = string | number无整个声明消失
function greet(user: User): stringfunction greet(user)参数和返回值类型被删除
Object.defineProperty(...)新增这是模块语法带来的,不是类型
关键结论

类型只服务于类型检查器。你可以随意重构类型,而不担心改变运行时行为——前提是这个语法本身是"可擦除"的。

哪些语法是可擦除的

可擦除(erasable) 指编译后该语法完全消失,不留任何运行时痕迹:

不可擦除(non-erasable) 指编译后会生成运行时代码:

第二类才是有运行时成本的部分。逐个看。

不可擦除的四种主要语法

 1// 这些语法在编译后留下运行时产物,因此 Node 原生 type stripping 无法执行
 2
 3// 1. 非 const enum → IIFE + 双向映射对象
 4enum Direction {
 5  Up,
 6  Down,
 7}
 8
 9// 2. namespace → IIFE
10namespace Geometry {
11  export const PI = 3.14;
12}
13
14// 3. 参数属性 → 构造函数里的赋值语句
15class Point {
16  constructor(
17    public x: number,
18    private label: string,
19  ) {}
20
21  get name(): string {
22    return this.label;
23  }
24}
25
26// 4. 类字段初始化 → 构造函数里的赋值语句
27class Counter {
28  count = 0;
29}
30
31const p = new Point(1, "origin");
32console.log(Direction.Up, Direction[0]);
33console.log(Geometry.PI);
34console.log(p.x, p.name);
35console.log(new Counter().count);

运行结果:

10 Up
23.14
31 origin
40

编译产物(tsc --target es2022 --module commonjs):

 1"use strict";
 2// 1. 非 const enum → IIFE + 双向映射对象
 3var Direction;
 4(function (Direction) {
 5    Direction[Direction["Up"] = 0] = "Up";
 6    Direction[Direction["Down"] = 1] = "Down";
 7})(Direction || (Direction = {}));
 8// 2. namespace → IIFE
 9var Geometry;
10(function (Geometry) {
11    Geometry.PI = 3.14;
12})(Geometry || (Geometry = {}));
13// 3. 参数属性 → 构造函数里的赋值语句
14class Point {
15    x;
16    label;
17    constructor(x, label) {
18        this.x = x;
19        this.label = label;
20    }
21    get name() {
22        return this.label;
23    }
24}
25// 4. 类字段初始化 → 构造函数里的赋值语句
26class Counter {
27    count = 0;
28}

1. enum → IIFE 与反向映射

1enum Direction { Up, Down }

生成一个 IIFE,把 Direction 变成一个双向映射对象:

1var Direction;
2(function (Direction) {
3    Direction[Direction["Up"] = 0] = "Up";
4    Direction[Direction["Down"] = 1] = "Down";
5})(Direction || (Direction = {}));

Direction["Up"] = 0 先赋值为 0,表达式的值也是 0,所以外层 Direction[0] = "Up" 同时建立了反向映射。这就是 Direction[0] === "Up" 能成立的原因。

注意 Direction 是 var 而不是 const:IIFE 需要它先在外部作用域存在。

2. namespace → IIFE

1namespace Geometry { export const PI = 3.14; }

同样生成 IIFE。这是 TypeScript 早期用来模拟模块的方案,现在 ES 模块已经取代了它的主要用途。

3. 参数属性 → 构造函数赋值

1constructor(public x: number, private label: string) {}

编译成:

1constructor(x, label) {
2    this.x = x;
3    this.label = label;
4}

参数上的 public / private / protected / readonly 不只是类型层面的可见性标记——它触发了实际的赋值代码。而单独写在成员声明上的可见性修饰符(private label: string; 不带参数属性)则是纯类型的,会被擦除。

这个区别容易踩

private label: string 的 private 只在编译期检查,运行时 p.label 照样能读到。真正需要运行时私有请用 #label(ES 私有字段)。

4. 类字段初始化 → 构造函数赋值

1class Counter { count = 0; }

count = 0 生成的是实例字段,每个实例都有自己的 count。这与把它写在构造函数里等价,但与写在原型上不同。

注意产物里保留了 x; 和 label; 两行字段声明。这是 useDefineForClassFields(target 为 es2022 及以上时默认为 true)的语义:字段用 Object.defineProperty 语义定义,而不是简单赋值。这个差异会在继承场景下表现为"子类字段覆盖父类访问器"。

这套边界推出的结论

理解了类型擦除,很多看似孤立的问题就有了统一解释。

结论一:不能用类型做运行时判断

1interface User { name: string }
2
3function isUser(value: unknown): boolean {
4  return value instanceof User;  // 错误:User 只在类型空间存在
5}

interface 编译后消失,instanceof 右边需要一个运行时存在的构造函数。所以 instanceof 只能用于 class,不能用于 interface 或 type。详见 类既是值又是类型。

替代方案是"类型谓词 + 属性检查",见 类型收窄。

结论二:typeof 在类型位置是另一回事

1const config = { debug: true };
2
3// 运行时判断:typeof 是 JS 运算符,返回字符串
4if (typeof config === "object") { }
5
6// 编译期查询:typeof 是 TS 类型运算符,返回类型
7type Config = typeof config;

同一个关键字,在值位置和类型位置含义完全不同。见 typeof 的两种含义。

结论三:Node 原生只能跑可擦除语法

Node.js 从 v22.18 / v23.6 起默认启用 type stripping:直接删除类型注解然后执行,不做类型检查、不做代码转换。

本地实测(Node v26.8.1):

1$ node content/typescript/src/type-erasure/erased.ts
2hi rainboy 1 hello
3
4$ node content/typescript/src/type-erasure/not-erasable.ts
5SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
6    code: 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX'

同一个目录、同样是 .ts,第一个能跑,第二个报错——差别只在有没有不可擦除语法。错误码 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 就是在说"这段代码不是纯类型擦除能处理的"。

需要执行含 enum / namespace / 装饰器的代码,用 tsx:

1$ npx tsx content/typescript/src/type-erasure/not-erasable.ts
20 Up
33.14
41 origin
50

这是 运行 TypeScript 的核心取舍:type stripping 零依赖、零配置,但只覆盖可擦除子集;tsx 覆盖全部语法,代价是多一个依赖。

结论四:类型断言不改变运行时值

1const value: unknown = "text";
2const len = (value as string).length;

as string 只是让类型检查器闭嘴,不插入任何转换。如果 value 实际是数字,as string 不会报错,.length 会在运行时得到 undefined。

这与"类型断言不进行运行时校验"是同一件事——类型信息运行时不存在,所以没有东西可以校验。

想要运行时校验

需要显式写检查代码(类型收窄),或引入 zod / valibot 这类在运行时携带 schema 的库。

结论五:装饰器必须生成代码

装饰器要修改类的运行时行为,所以必然不可擦除。这也是它需要单独配置的原因:

1// legacy 装饰器
2{ "experimentalDecorators": true }

标准装饰器(TC39 stage-3)不需要 experimentalDecorators,但同样不可擦除——编译后依然会调用装饰器函数。两种路线的区别见 装饰器(legacy) 与 装饰器(标准)。

一个反直觉的推论

既然类型在运行时不存在,“类型安全的库” 这个说法在跨进程边界时是失效的。

1async function getUser(id: string): Promise<User> {
2  return fetch(`/api/user/${id}`).then(r => r.json());
3}

Promise<User> 是编译期的承诺。运行时 r.json() 返回什么,取决于服务器。如果服务器返回 { name: 123 },类型检查器不会报错,而下游代码会在很远的地方以奇怪的方式失败。

这就是为什么"类型擦除"必须和"运行时校验"配合使用——尤其是在网络、文件、JSON.parse、process.env 这些边界上。

小结

问题答案
类型在运行时存在吗不存在,tsc 第 3 步就丢弃了
哪些语法会留下运行时代码enum、namespace、参数属性、类字段、装饰器、import =
为什么 instanceof 不能用 interfaceinterface 编译后消失,instanceof 需要运行时构造函数
为什么 Node 跑不了 enumtype stripping 只做删除,enum 需要代码转换
as 会转换值吗不会,它只是类型层面的断言
跨进程边界类型还有效吗无效,需要在边界做运行时校验

下一篇:类型基础。