TypeScript 类型体操实战
TypeScript 类型体操实战
类型体操不是炫技,而是在编译期构建一套与运行时逻辑一一对应的类型契约。
一、引言:为什么需要类型体操
TypeScript 的类型系统是一门图灵完备的语言。这意味着,凡是能在运行时用 JavaScript 表达的逻辑,理论上都能在编译期用类型系统表达。
日常开发中,我们写的类型往往是"被动"的——给变量标个 string,给函数参数加个接口。但当你开始封装库、设计 DSL、或者处理复杂的泛型推导时,"主动"编写类型逻辑就变得不可避免。这就是**类型体操(Type Gymnastics)**的用武之地。
本文不会从 extends 和 infer 的语法定义讲起,而是直接切入五个由浅入深的实战场景,带你理解类型体操的真正工作方式。
二、前置武器库
在实战之前,先确认你熟悉以下三个核心机制:
| 机制 | 作用 | 示例 |
|---|---|---|
| 条件类型 | 类型层面的 if/else | T extends U ? X : Y |
infer 推断 | 从类型结构中提取子类型 | T extends Promise<infer R> ? R : never |
| 映射类型 | 批量转换对象属性 | { [K in keyof T]: ... } |
这三者的组合,构成了类型体操的全部基础语法。接下来的所有案例,都只是它们的排列组合。
三、实战案例
案例 1:DeepReadonly —— 递归类型的入门
需求:实现一个深度 Readonly,让对象的所有层级属性都不可变。
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
// 测试
interface User {
name: string;
address: {
city: string;
coords: [number, number];
};
}
const user: DeepReadonly<User> = {
name: "Alice",
address: { city: "Beijing", coords: [116, 39] },
};
user.address.city = "Shanghai"; // ❌ TS Error: Cannot assign to 'city' because it is a read-only property
要点分析:
extends object判断是否为引用类型extends Function排除函数(函数也是 object,但通常不需要递归只读)- 递归调用
DeepReadonly<T[K]>实现深度遍历
案例 2:TupleToUnion —— 元组的解构艺术
需求:将元组类型转换为联合类型。
type TupleToUnion<T extends readonly any[]> = T[number];
// 或者手动实现(用于理解索引访问)
type TupleToUnion2<T extends readonly any[]> = T extends readonly [infer First, ...infer Rest]
? First | TupleToUnion2<Rest>
: never;
type Result = TupleToUnion2<["a", "b", "c"]>; // "a" | "b" | "c"
要点分析:
T[number]是元组索引访问的语法糖,直接得到所有元素的联合类型- 手动递归版本展示了
infer配合剩余参数(...infer Rest)的模式,这是处理数组/元组的经典手法
案例 3:Promise.All 的类型推断 —— 从值到类型的映射
需求:实现一个类型安全的 PromiseAll,能够正确推断返回的元组类型。
declare function PromiseAll<T extends readonly unknown[]>(
values: readonly [...T]
): Promise<{
-readonly [K in keyof T]: Awaited<T[K]>;
}>;
// 测试
const p1 = Promise.resolve(1);
const p2 = Promise.resolve("hello");
const p3 = Promise.resolve(true);
const result = await PromiseAll([p1, p2, p3] as const);
// result 的类型为: [number, string, boolean]
要点分析:
readonly [...T]使用展开语法保留元组的结构(而非退化为数组)-readonly移除只读修饰符(与readonly相反)Awaited<T[K]>(TS 4.5+ 内置)递归解包 Promise 类型
案例 4:路由参数提取 —— 字符串模板的类型解析
需求:从 /user/:id/profile/:type 这样的路由字符串中提取参数名。
type ExtractParams<T extends string> =
T extends `${infer Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${infer Start}:${infer Param}`
? { [K in Param]: string }
: {};
// 测试
type Params = ExtractParams<"/user/:id/profile/:type">;
// { id: string; type: string }
要点分析:
- 模板字面量类型(Template Literal Types)是 TS 4.1 引入的利器
- 通过
${infer Start}:${infer Param}/${infer Rest}进行模式匹配,类似正则的分组捕获 - 递归处理剩余路径,最终将多个参数合并为一个对象类型
进阶版本:支持可选参数 ? 标记。
type ExtractParamsAdvanced<T extends string> =
T extends `${infer Start}:${infer Param}?/${infer Rest}`
? { [K in Param]?: string } & ExtractParamsAdvanced<Rest>
: T extends `${infer Start}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ExtractParamsAdvanced<Rest>
: T extends `${infer Start}:${infer Param}?`
? { [K in Param]?: string }
: T extends `${infer Start}:${infer Param}`
? { [K in Param]: string }
: {};
type R = ExtractParamsAdvanced<"/shop/:shopId/item/:itemId?">;
// { shopId: string } & { itemId?: string }
案例 5:类型安全的 EventEmitter —— 从字符串到结构化事件
需求:实现一个 EventEmitter,使得 on("eventName", handler) 中的 handler 参数类型由事件名自动推导。
type EventMap = {
login: { userId: string; timestamp: number };
logout: { userId: string };
error: { message: string; code: number };
};
class TypedEventEmitter<Events extends Record<string, any>> {
private handlers = new Map<string, Set<Function>>();
on<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void
): void {
if (!this.handlers.has(event as string)) {
this.handlers.set(event as string, new Set());
}
this.handlers.get(event as string)!.add(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const set = this.handlers.get(event as string);
if (set) {
set.forEach((fn) => fn(payload));
}
}
}
// 使用
const emitter = new TypedEventEmitter<EventMap>();
emitter.on("login", (payload) => {
console.log(payload.userId); // ✅ string
console.log(payload.timestamp); // ✅ number
});
emitter.on("error", (payload) => {
console.log(payload.code); // ✅ number
console.log(payload.userId); // ❌ TS Error: Property 'userId' does not exist
});
要点分析:
K extends keyof Events将字符串字面量约束为事件名的联合类型Events[K]实现索引访问,自动关联到对应的 payload 类型- 这是结构化类型与泛型约束结合的经典范式,广泛应用于状态管理库(如 Zustand、Pinia)的设计中
四、类型体操的调试哲学
写类型体操时,最常见的痛苦是报错信息像天书。以下是三个实用的调试技巧:
1. 利用 "类型查看" 技巧
当你不确定某个复杂类型的推导结果时,用一个辅助类型将其"暴露"出来:
type Debug<T> = T; // 鼠标悬停查看
type Test = Debug<ExtractParams<"/a/:x/b/:y">>;
2. 分而治之
不要试图一次性写出完整的递归类型。先写单层逻辑,确认无误后再套递归:
// 第一步:处理单层
type Single<T> = T extends `:${infer P}/${string}` ? P : never;
type S = Single<":id/name">; // "id" ✅
// 第二步:扩展到递归
type Multi<T> = T extends `:${infer P}/${infer Rest}`
? P | Multi<Rest>
: T extends `:${infer P}`
? P
: never;
3. 善用 Equal 测试类型
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
type Test1 = Equal<ExtractParams<"/:a">, { a: string }>; // true
type Test2 = Equal<ExtractParams<"/:a">, { b: string }>; // false
五、什么时候不该用类型体操
类型体操是把双刃剑。以下场景不建议过度使用:
| 场景 | 原因 |
|---|---|
| 运行时数据校验 | 类型只在编译期存在,无法替代 zod 或 yup 的运行时校验 |
| 过度复杂的条件分支 | 当类型逻辑超过 3 层嵌套时,考虑是否在运行时处理更合适 |
| 团队水平参差不齐 | 类型体操会显著提高代码的认知门槛 |
黄金法则:类型体操应该消除运行时错误,而不是替代运行时逻辑。
六、总结
类型体操的底层语法只有三条:
- 泛型 —— 类型的函数参数
- 条件类型 —— 类型的分支判断
infer+ 映射 —— 类型的解构与重组
所有复杂的类型库——从 lodash 的类型定义到 Prisma 的查询类型——都是这三者的组合。掌握本文的五个实战案例,你已经具备了阅读绝大多数开源库类型定义的能力。
类型系统的终极目标,是让**"如果代码能编译通过,那么它大概率不会运行时报错"**。类型体操,就是你在编译期为代码建立的最强防线。
延伸阅读:如果你想挑战更高难度,可以尝试实现
TupleToObject、FlattenDepth、Currying等经典类型体操题目,type-challenges 仓库是最好的练习场。