TypeScript 类型的运行时退场:从编译期优化到 monomorphic 调用点的性能闭环
引言
TypeScript 常被误解为「只在编译期起作用,运行时零成本」。后半句是对的——类型擦除后不会留下任何运行时检查;但前半句漏了一个关键环节:你写的类型,会反向塑造编译后 JS 的对象形状,而对象形状正是 V8 内联缓存判单态还是多态的依据。于是类型设计顺理成章地成了性能的前置变量。本文拆解这条从类型到引擎的隐秘链路。
一、类型擦除不等于形状无关
TS 把类型删掉了,但删不掉你「怎么构造对象」这件事。一个 interface 不会跑到运行时,可你为了让类型满足它而写的字段赋值顺序,会原封不动地留下来。
// 反例:为了满足类型,在不同分支里以不同顺序赋值
interface User { id: number; name: string; role: string }
function makeUser(isAdmin: boolean): User {
const u: Partial<User> = {};
u.name = 'LX';
if (isAdmin) {
u.role = 'admin'; // 先 role 后 id
u.id = 1;
} else {
u.id = 2; // 先 id 后 role
u.role = 'viewer';
}
return u as User;
}
类型检查器满意了,但两个分支产出了两个不同的隐藏类。编译后的 u.id 访问,IC 在两个 Map 间切换。
// 正例:让「类型字段声明顺序」与「运行时赋值顺序」对齐
interface User { id: number; name: string; role: string }
function makeUser(isAdmin: boolean): User {
const u: User = { id: 0, name: 'LX', role: 'viewer' }; // 一次声明全部字段
if (isAdmin) { u.id = 1; u.role = 'admin'; }
return u;
}
对象字面量一次性写全字段,隐藏类在构造瞬间定型,后续赋值只改值不改形状。类型声明在这里成了「形状模板」,帮你把运行时可能的形状分叉提前拍平。
二、联合类型的运行时成本:判别字段决定 IC 命运
联合类型是 TS 最强大的特性,但它的价值在编译期,隐患在运行时——不同成员若形状差异过大,会在消费端制造多态。
// 反例:联合成员缺少统一判别字段,消费处靠类型断言硬转
type Result = { ok: true; data: string } | { ok: false; error: string };
function handle(r: Result) {
// 运行时其实是安全的,但对象形状完全不同,属性和类都在漂移
if (r.ok) return r.data;
return (r as any).error; // 靠 any 绕过,形状也更难被引擎预判
}
{ ok: true; data } 和 { ok: false; error } 是两个截然不同的隐藏类。消费函数同时处理两种形状,IC 直接多态。
// 正例:用统一的前置判别字段 + 显式结构,让消费点保持形状稳定
type Result =
| { kind: 'success'; value: string }
| { kind: 'failure'; message: string };
function handle(r: Result) {
switch (r.kind) {
case 'success': return r.value;
case 'failure': return r.message;
}
}
kind 作为字面量判别字段,让 TS 收窄更顺,也让每个 case 分支内部的属性访问各自保持单态——这是「可辨识联合」在性能侧的隐藏红利。
三、const 断言:把易变形状锁成常量形状
as const 常被当成「让类型更窄」的工具,但它同时有一个运行时副产物:把对象锁成只读、固定形状,恰好契合 V8 对稳定形状的偏好。
// 反例:枚举式配置被当成可变对象,形状在运行时可被改写
export const COLORS = {
primary: '#333',
secondary: '#666',
};
// 类型是 { primary: string; secondary: string },字段可改,形状可被破坏
// 正例:as const 同时定死类型与形状
const COLORS = {
primary: '#333',
secondary: '#666',
} as const;
// 类型是 { readonly primary: '#333'; readonly secondary: '#666' }
as const 之后,字段既不能改值也不能新增,隐藏类从「可变容器」退化为「稳定形状」。对高频读取的配置对象,这是零成本的形状优化。
四、类型收窄的「提前」与「延后」
类型收窄的位置,决定了运行时判断发生多少次。把收窄放在循环外还是循环内,性能天差地别,这也直接反映在 IC 的稳定性上。
// 反例:把类型收窄放进热循环里,每次迭代都做一次形状判断
function sum(items: (number | string)[]) {
let total = 0;
for (const it of items) {
if (typeof it === 'number') { // 每次循环都判断,IC 在多态间抖动
total += it;
} else {
total += Number(it);
}
}
return total;
}
// 正例:在入口一次性收敛类型,循环体只处理单一类型
function sum(items: (number | string)[]): number {
const nums = items.map((it) => (typeof it === 'number' ? it : Number(it)));
return nums.reduce((a, b) => a + b, 0);
}
先 map 成统一的 number[],循环体(reduce)只面对一种类型。类型判断从「每次迭代」降为「一次遍历」,内层 IC 保持单态。
五、类型设计如何写进性能契约
把上面的观察收敛成三条可执行原则,让「类型安全」与「引擎友好」不再对立:
// 正例:性能契约三原则
// 1. 接口字段的声明顺序 = 对象字面量的赋值顺序(形状一次性定型)
// 2. 联合类型必须带统一判别字段(可辨识联合),消费点按 kind 分派
// 3. 类型收窄放在数据入口,热循环只吃单一类型(单态 IC)
// 反模式速查
// any 断言:绕过类型检查,也摧毁了引擎的形状预判
// 分支内不同赋值顺序:类型通过,隐藏类分叉
// 循环内收窄:类型正确,IC 多态抖动
这五条里,没有一条要求你「为了 V8 牺牲类型安全」。恰恰相反,它们都是更严谨的类型设计的自然结果——严谨的类型,往往也是稳定形状的类型。
结语
TypeScript 的类型确实在运行时消失,但它通过「对象形状」这条暗线,持续影响着 V8 的优化决策。真正的性能红利不在「少写几个类型」,而在让类型的设计本身产出稳定形状:对象一次性定型、联合带判别字段、收窄提前到入口。当你的类型越精确,编译后的对象形状就越稳定,引擎的单态假设就越容易成立。类型系统的价值,最终落在了它从未宣称过的地方——运行时。
评论区
登录 后参与评论