《TypeScript高级编程》8.2 类型擦除后的运行时形态

类型擦除不是一句「类型都没了」就能带过:本节逐条对照 .ts 与编译产物,看清哪些语法被彻底删除、哪些留下了运行时代码。重点讲 enum、namespace、参数属性、装饰器与类字段的真实编译结果,以及为什么 private 与 #private 在运行时完全不同。读完你能预判任意一段 TS 会编译成什么,并知道何时必须补运行时校验。

本节目标:把「类型擦除」从一个口号变成一份可核对的清单。读完你能准确说出任意一段 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 性能剖析与火焰图 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes