TypeScript 的类型系统已经强大到可以在编译期做很多"计算"。这篇文章汇总一些工程中常用、好用的高级写法,附带示例代码,并且逐一讨论它们在运行时和编译期是否有性能损失——这是很多文章不会讲清楚的地方。
TypeScript 的类型系统在编译后会被完全擦除,所以本节所有技巧运行时开销为 0,代价只体现在编译速度上。
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<123>; // false
条件类型是类型体操的基础,可以配合 infer 提取类型信息:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Result = UnwrapPromise<Promise<number>>; // number
当条件类型作用于联合类型上时,会自动"分发"到每个成员:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// 等价于 string[] | number[],而不是 (string | number)[]
如果不想要这种分发行为,可以用 [T] 包裹阻止分布:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// (string | number)[]
TS 4.1+ 支持在映射类型里用 as 子句重命名键,常用来做 getter/setter 生成:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
配合 -? -readonly 可以移除可选/只读修饰符:
type Required<T> = { [K in keyof T]-?: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
可以在类型层面做字符串拼接、解析:
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"
// 反向解析路由参数
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/user/:id/post/:postId">;
// "id" | "postId"
TS 支持有限深度的递归类型,常用于深度 Partial、Readonly:
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
⚠️ 注意:递归深度过深(一般 >50 层)会触发 Type instantiation is excessively deep 错误,这是一个真实的编译期限制,不是性能损失但会导致编译失败。
function isString(val: unknown): val is string {
return typeof val === "string";
}
// 结合数组 filter,TS 能正确收窄类型
const mixed: (string | number)[] = ["a", 1, "b", 2];
const strings = mixed.filter(isString); // string[]
TS 5.5+ 还支持自动推断的类型谓词(无需手写 is),编译器会在满足条件时自动识别。
satisfies 既能做类型检查,又不会像类型标注那样"扩宽"字面量类型:
type Config = {
mode: "dark" | "light";
retries: number;
};
// 用 : Config 标注会丢失字面量精度
const config1: Config = { mode: "dark", retries: 3 };
config1.mode; // 类型是 "dark" | "light"
// 用 satisfies 保留字面量精度,同时做类型检查
const config2 = { mode: "dark", retries: 3 } satisfies Config;
config2.mode; // 类型是 "dark"(精确字面量)
这在需要精确类型推导(比如给 Redux action 或路由表做类型约束)时非常实用。
const routes = ["/home", "/about", "/contact"] as const;
// 类型是 readonly ["/home", "/about", "/contact"]
// 而不是 string[]
type Route = typeof routes[number];
// "/home" | "/about" | "/contact"
as const 常和模板字面量类型配合,把运行时的常量数组转换成编译期的联合类型,做路由、权限枚举校验非常好用。
const city = user?.address?.city ?? "未知城市";
编译到 ES2020 及以上目标时,?. 和 ?? 会被保留为原生语法,没有额外开销;编译到更老的目标(如 ES5)会被转译成多次 null/undefined 判断,有极轻微的运行时开销(多几次条件判断),但可以忽略不计。
function logMethod(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`调用 ${key}`, args);
return original.apply(this, args);
};
}
class Service {
@logMethod
fetchData(id: number) {
return id;
}
}
⚠️ 装饰器有真实的运行时开销:它们在类定义时执行一次(不是每次调用),但如果装饰器内部包裹了方法(如上面的日志装饰器),那么每次方法调用都会多一层函数调用和闭包访问,开销通常是微秒级,高频调用路径(如渲染循环、热路径计算)需要谨慎使用。
// 数字枚举:编译后生成双向映射对象,有运行时体积和查表开销
enum Color { Red, Green, Blue }
// const enum:编译期内联,无运行时产物(但项目引用/隔离编译场景下有限制)
const enum Direction { Up, Down }
// 联合类型字面量:零运行时开销,纯类型层面
type Status = "pending" | "success" | "error";
性能对比是本节最值得关注的点:
enum 会编译出一个真实的 JS 对象(双向映射),有内存占用和轻微查找开销,在 tree-shaking 时也不容易被优化掉。const enum 在编译期被内联为字面量,理论上零开销,但在 isolatedModules(如 Babel、esbuild、SWC 单文件转译场景)下会被禁用或报错,Vite/Next.js 等现代工具链通常不推荐使用。"a" | "b" | "c")是目前社区推荐的做法:类型安全 + 零运行时开销,缺点是不能像 enum 一样做反向查值或运行时遍历(需要额外定义一个 as const 数组配合 typeof 使用,见 1.8)。function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
泛型本身没有运行时开销(编译后擦除),但泛型函数内部逻辑(比如上面的 for 循环)该有什么开销还是有什么开销,泛型只是让这段逻辑在类型层面更安全。
| 技巧 | 编译期开销 | 运行时开销 | 备注 |
|---|---|---|---|
| 条件类型 / infer / 模板字面量类型 | 中~高(复杂时显著拖慢 tsc) | 无 | 类型全部在编译期擦除 |
| 深度递归类型 | 高,可能超出深度限制报错 | 无 | 注意善用 tail-recursion 优化写法 |
satisfies | 极低 | 无 | 纯类型检查,无产物变化 |
as const | 极低 | 无 | 只影响类型推导 |
可选链 ?. / ?? | 低 | 目标为 ES2020+ 时为 0;转译到 ES5 时有极小开销 | 现代浏览器/Node 均原生支持 |
| 装饰器 | 低 | 有,每次方法调用多一层闭包 | 高频路径慎用 |
数字 enum | 低 | 有内存 + 查表开销,且不利于 tree-shaking | 建议用联合类型字面量替代 |
const enum | 极低 | 无(内联) | 现代构建工具链下有兼容性风险 |
| 泛型 | 中(复杂泛型推导拖慢 IDE 响应) | 无 | 类型擦除,逻辑本身开销不变 |
核心结论:TypeScript 绝大多数"高级技巧"都发生在类型层面,编译后被完全擦除,不会带来任何运行时性能损失。真正需要关注运行时性能的只有三类:
as const。tsc 编译速度和编辑器的类型推导响应速度(IDE 卡顿),在大型项目中属于隐性的"开发体验性能损失",需要用 type-coverage、tsc --extendedDiagnostics 等工具监控。as const**替代传统 enum,兼顾类型安全和运行时性能。satisfies 应该成为定义配置对象、路由表、状态机的默认选择,替代直接的类型标注。DeepPartial、ExtractParams 等)建议统一放进 types/utils.ts,避免在业务代码里到处手写递归类型导致编译变慢。experimentalDecorators(旧版,NestJS/Angular 仍在用)两套实现,选型时注意版本兼容性,二者语义和执行时机有差异。// @ts-expect-error 配合单测断言类型行为,防止后续重构悄悄破坏类型推导。TypeScript 类型系统的强大之处在于:绝大多数"聪明"的写法都是编译期的免费午餐。真正要花心思权衡的性能问题,反而集中在少数几个会生成运行时产物的特性上(enum、装饰器)。把这条线分清楚,才能既写出优雅的类型代码,又不给运行时性能挖坑。
暂无评论,快来抢沙发吧!