《TypeScript高级编程》1.2 类型擦除与运行时边界

本节把 tsc 的编译产物摊开看,讲清类型擦除删掉了什么、留下了什么。内容涵盖擦除的基本规则与产物对照、泛型与 instanceof 为何在运行时不可用、as 断言为何不是校验,以及 enum、namespace、类体、装饰器元数据这些必须生成真实代码的例外。读完你能列出运行时可用的替代手段,用类型谓词与判别联合把校验集中在系统边界,并判断 private、readonly 的真实效力。

本节目标:看清 tsc 编译后究竟留下了什么、擦掉了什么。读完你能列出「不擦除」的少数语法、解释泛型与 instanceof 为何无法在运行时使用、明白 as 断言为什么不是校验,并知道在边界处该如何用运行时守卫补齐。

1.2 类型擦除与运行时边界

上一节的兼容性规则全部发生在编译期。本节换一个视角:把编译产物摊开看。你会看到一个反直觉的事实——绝大多数类型信息被完整删除,但有少数语法必须保留运行时代码,而这些「残留」正是许多坑的来源。

一、擦除的基本规则:类型是注释

TypeScript 的设计目标之一是「类型不产生运行时开销」。因此编译器对类型标注的处理方式是整体删除:

interface User { id: number; name: string }
type Handler = (u: User) => void;

function greet(user: User): string {
  return `Hello, ${user.name}`;
}

const n: number = 1;

编译产物(target: es2020、removeComments: false):

function greet(user) {
    return `Hello, ${user.name}`;
}
const n = 1;

interface、type、参数标注、返回标注、变量标注全部消失。产物的信息量等于「把标注手动删掉的 JS」。这就是类型擦除(type erasure)。记住这个心智模型:类型是写给编译器看的注释,不是运行时数据。

二、运行时盲区:三种「看起来能用、其实不能用」的写法

因为类型不存在于运行时,以下写法在类型系统里「说得通」,但产物中无从落地:

interface Point { x: number; y: number }

function isPoint(v: unknown): boolean {
  return v instanceof Point;
  // 报错:'Point' only refers to a type, but is being used as a value here.
}

interface 没有对应的运行时构造器,instanceof 找不到东西可查。同样的盲区还有泛型:

function create<T>(): T {
  return new T();
  // 报错:'T' only refers to a type, but is being used as a value here.
}

function tag<T>(v: T): string {
  return typeof T; // 报错:'T' only refers to a type...
}

以及 as 断言——它不是校验,是单方面的声明:

type Config = { port: number };

const raw: unknown = JSON.parse('{"port":"8080"}');
const cfg = raw as Config;
console.log(cfg.port + 1); // 产物里没有任何检查,"8080" + 1 得到 "80801"

as 在产物中完全消失。类型对不上时,编译器不报错,运行时也不报错,错误被推迟到使用它的那一行。这是「边界数据必须校验」的根本原因,也是 边界数据与不可信输入 一节的出发点。

三、运行时可用的替代手段

既然类型没了,边界校验只能靠运行时的值本身。可用的工具其实只有几类:

手段适用局限
typeof v === 'string'原始类型无法区分 null 与对象(历史遗留)
Array.isArray(v)数组只判数组
v instanceof C类实例跨 realm(iframe、worker)会失效
'key' in v / Object.hasOwn(v, k)字段存在性不判字段类型
手写类型谓词 v is T任意复杂结构每个分支都得自己写对

其中类型谓词是把运行时判断「回喂」给编译器的唯一正规通道:

function isPoint(v: unknown): v is Point {
  return (
    typeof v === 'object' && v !== null &&
    typeof (v as Point).x === 'number' &&
    typeof (v as Point).y === 'number'
  );
}

const data: unknown = JSON.parse(input);
if (isPoint(data)) {
  data.x.toFixed(2); // 此处 data 被收窄为 Point
}

注意 v is Point 是编译期的承诺:函数体写错,编译器不会替你发现——它只检查返回值是不是 boolean。手写谓词极易写漏分支,工程上更常见的做法是交给验证库自动生成谓词,原理见 类型守卫与验证库原理 与 TypeScript 运行时验证与类型安全 。

四、少数「不擦除」的语法

擦除是默认规则,但有几种语法必须生成运行时代码,因为它们提供了真实的运行时语义:

enum Direction { Up, Down, Left, Right }
// 产物:
// var Direction;
// (function (Direction) {
//     Direction[Direction["Up"] = 0] = "Up";
//     Direction[Direction["Down"] = 1] = "Down";
//     // ...
// })(Direction || (Direction = {}));

enum 会生成双向映射对象,所以它是有运行时代价的类型。相比之下 const enum 会在编译期把引用处内联替换成字面量、不生成对象——但它与 isolatedModules、verbatimModuleSyntax 冲突,跨包发布时常常不可用。

其余不擦除的语法汇总如下:

语法产物注意
enum双向映射对象有运行时体积
const enum内联字面量,无对象与 isolatedModules 冲突
namespace(含值)IIFE与 ESM 混用有风险
类原样保留(标注被删)private 只是编译期约束
装饰器 + emitDecoratorMetadata__decorate 辅助函数 + design:type 元数据需 reflect-metadata 才可读
参数属性 constructor(public x: number)赋值语句 this.x = x只擦标注,不擦赋值

其中 private 值得单独强调:它只存在于编译期。

class Wallet {
  private balance = 0;
}

const w = new Wallet();
(w as any).balance = 1e9; // 编译通过,运行时真的改掉了

要真正的运行时私有,只能用 JS 原生的 #balance。同理,readonly 在运行时也是可写的——它只是防止你在类型系统内写入。

五、类型-only 的写法与它们的产物

擦除规则还解释了另一类「零成本」写法:它们存在的唯一目的就是让编译器看到更多信息,产物里必须什么都不剩。

import type { User } from './types';        // 只导入类型,产物中整行消失
export type { User };                        // 同上

declare const version: string;               // 只声明,不产物
declare function fetchIt(): Promise<void>;   // 同上

class Cache {
  declare store: Map<string, number>;        // 声明字段,不产物、不初始化
}

const n = 1 as const;                        // as const 编译期生效,产物是 const n = 1
const obj = { a: 1 } satisfies Record<string, number>; // satisfies 不产物
写法产物说明
import type { T }整行删除确保不引入运行时副作用
declare const / declare function无纯类型层面的声明
class { declare x }无不给字段发初始化代码
as const仅保留值只改变推导结果
satisfies T无只做校验,不改变推导类型
v as T无只做断言,不做校验
v!无非空断言,产物里直接消失

import type 与普通 import 在类型检查上等价,但在产物上有本质差别:普通 import 是否保留取决于 verbatimModuleSyntax 等开关,而 import type 保证被删除。这对库作者尤其重要——它决定了你的包会不会把无意的副作用带进使用方,也与循环依赖的消除直接相关,见 循环依赖与类型-only 导入 。

satisfies 值得单独强调,因为它常被误当成 as:as 是单方面声明(类型对不上也强行通过),satisfies 是真校验(对不上就报错,但保留推导出的窄类型)。

const a = { mode: 'dark' } as Record<string, string>;
a.notExist.toUpperCase(); // 编译通过,运行时崩

const b = { mode: 'dark' } satisfies Record<string, string>;
b.mode.toUpperCase();     // OK:mode 仍是字面量 "dark"
// b.notExist;            // 报错:Property 'notExist' does not exist

一句话记住:as 让编译器闭嘴,satisfies 让编译器开口,但两者都不产物。

六、断言的三种写法与它们的共同真相

as T、<T>v、v! 三种断言在产物里全部消失,它们的差别只在语法与限制:

const a = raw as User;      // 通用形式,可用于任何位置
const b = <User>raw;        // 尖括号形式,在 .tsx 中与 JSX 冲突,不推荐
const c = maybeUser!.name;  // 非空断言,只去掉 null | undefined

! 尤其容易被滥用,因为它看起来「很轻」:

function find(id: string): User | undefined { /* ... */ }

const u = find('u-1')!;
u.name.toUpperCase(); // 编译通过;找不到用户时运行时 TypeError

正确的做法是用控制流收窄替代断言:

const u = find('u-1');
if (!u) throw new Error('user not found');
u.name.toUpperCase(); // 这里 u 已被收窄为 User,无需断言

三条断言的共同点是:它们都是「信任我」的标记,而不是检查。把断言数量当作代码健康度的指标,! 与 as any 越少,边界的可靠性越高。as unknown as T 这种「双重断言」更是明确的信号——它意味着你在强行跨越两个互不兼容的类型,通常应当先补上真正的校验。

七、擦除之后:值在 V8 里长什么样

类型擦除意味着运行时只剩普通 JS 值。这些值的「形状」由 JS 引擎根据属性添加顺序决定,而不是由类型声明决定:

interface P { x: number; y: number }

const a: P = { x: 1, y: 2 };
const b = { y: 2, x: 1 }; // 同一个 interface,但属性顺序不同

a 与 b 在 V8 中可能落入不同的隐藏类(hidden class),从而影响内联缓存的命中率与属性访问速度。这说明类型标注对性能没有直接帮助,真正起作用的是运行时形状是否稳定。这条线索会在 类型擦除后的运行时形态 展开,引擎侧机制可延伸阅读 V8 类型反馈与 JIT 与 动态语言的内联缓存 。

八、装饰器元数据:擦除规则的例外通道

唯一能「把类型信息带到运行时」的官方通道是装饰器元数据:

import 'reflect-metadata';

function Inject(): PropertyDecorator {
  return (target, key) => {
    const type = Reflect.getMetadata('design:type', target, key);
    console.log(`${String(key)} 的类型是 ${type.name}`);
  };
}

前提是 experimentalDecorators 与 emitDecoratorMetadata 两个开关同时打开。它只对能映射到运行时构造器的类型有效:string、number、类可以,interface、联合类型、泛型参数则退化为 Object。这也是 DI 容器必须用类而不是接口做 token 的根本原因,细节见 reflect-metadata 与依赖注入容器 ;装饰器本身的两种写法对照见 标准装饰器(TS 5.x) 。

九、常见坑与错误信息

坑一:以为 as 会做检查。

const n = 'abc' as unknown as number;
n.toFixed(2); // 编译通过,运行时 TypeError: n.toFixed is not a function

坑二:以为 interface 可以在运行时判等。

if (data instanceof MyInterface) { /* 永远无法编译 */ }

坑三:以为泛型能保留到运行时。

function wrap<T>(v: T): T[] { return [v]; }
console.log(wrap<string>('a')); // 产物是 wrap('a'),<string> 已被删除

坑四:把「已校验」写进注释而不是类型。 校验函数返回 boolean 时,调用方拿不到任何类型收益;返回 v is T 才能让控制流收窄生效。

坑五:以为 declare 会生成代码。 declare const x: string 在产物里不存在;如果你在运行时读它,会拿到 undefined 或直接抛 ReferenceError。declare 的语义是「我保证这个全局变量在运行时存在」,它是一句承诺,不是一段实现。

十、边界设计的实践清单

场景建议
外部输入(HTTP、文件、消息队列)一律视为 unknown,先用验证库解析成具体类型
内部函数间传递可信任类型标注,避免重复校验
需要运行时区分的分支用判别联合 + 字面量字段,而不是 instanceof
需要真私有用 #field,不要依赖 private
需要元数据用类 + 装饰器 + emitDecoratorMetadata,并接受它对接口无效
需要零副作用的类型导入用 import type,并打开 verbatimModuleSyntax
想校验又不想丢精度用 satisfies,而不是 as

把校验集中在边界,是「擦除」这一事实给工程带来的最重要约束:边界之内靠类型,边界之外靠值。这与 结构化类型与兼容性判定 的结论互为补充——那条规则管的是边界之内怎么判兼容,这一节管的是边界之外怎么保平安。

小结

本节把编译产物摊开看了一遍:

  1. 擦除是默认规则。interface、type、所有标注在产物中都不存在,as 断言也一并消失。
  2. 不擦除的是少数。enum、含值的 namespace、类体、装饰器元数据会生成真实代码;private、readonly 只在编译期有效。
  3. 边界必须用值校验。类型谓词、判别联合、验证库是仅有的三种正规手段。

到这里,「类型在运行时不存在」这件事已经说清。但既然编译期和运行时是两套世界,编译器又是如何在没有标注的地方猜出类型、如何让回调参数自动获得类型的?下一节 类型推导算法与上下文类型 进入编译器内部,看它推导与收窄的具体算法。

阅读导航:上一节:1.1 结构化类型与兼容性判定 · 下一节:1.3 类型推导算法与上下文类型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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