本节目标:看清
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 |
把校验集中在边界,是「擦除」这一事实给工程带来的最重要约束:边界之内靠类型,边界之外靠值。这与 结构化类型与兼容性判定 的结论互为补充——那条规则管的是边界之内怎么判兼容,这一节管的是边界之外怎么保平安。
小结
本节把编译产物摊开看了一遍:
- 擦除是默认规则。
interface、type、所有标注在产物中都不存在,as断言也一并消失。 - 不擦除的是少数。
enum、含值的namespace、类体、装饰器元数据会生成真实代码;private、readonly只在编译期有效。 - 边界必须用值校验。类型谓词、判别联合、验证库是仅有的三种正规手段。
到这里,「类型在运行时不存在」这件事已经说清。但既然编译期和运行时是两套世界,编译器又是如何在没有标注的地方猜出类型、如何让回调参数自动获得类型的?下一节 类型推导算法与上下文类型 进入编译器内部,看它推导与收窄的具体算法。
阅读导航:上一节:1.1 结构化类型与兼容性判定 · 下一节:1.3 类型推导算法与上下文类型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。