TypeScript 类型体操入门
从泛型到条件类型、映射类型与 infer,拆解几个高频工具类型的实现,让类型不再是写起来难受的负担。
很多人用 TypeScript 停留在「给变量加类型注解」的层面:函数写 : string、: number,遇到复杂一点的类型就绕道走。但类型系统真正强大的地方在于它也是一门可以编程的语言——用类型去推导类型、约束类型、甚至从已有类型里「计算」出新类型。这套玩法被戏称为「类型体操」。
这篇文章不堆砌奇技淫巧,只讲几个构成体操的基本动作,以及它们如何组合出你日常见到的工具类型。
起点:泛型
一切体操都建立在泛型之上。泛型就是类型的「参数」:
function identity<T>(value: T): T {
return value;
}
// 传入什么类型,返回什么类型,且两者被绑定
const a = identity<string>('hello');
const b = identity(42); // 自动推断为 number
把视角从函数泛型移到类型别名,就能定义「参数化的类型」:
type Box<T> = { value: T };
const box: Box<string> = { value: 'x' };
从这里开始,类型就可以像函数一样「接收参数、产出结果」了。
三大运算符:keyof / typeof / 索引访问
keyof 取出一个类型的所有键组成联合类型,typeof 在类型上下文里取一个值的类型,索引访问 T[K] 则像 JS 取属性一样取类型的子类型:
type User = {
id: number;
name: string;
role: 'admin' | 'user';
};
type UserKeys = keyof User; // 'id' | 'name' | 'role'
type NameType = User['name']; // string
const user = { id: 1, name: 'a', role: 'admin' as const };
type T = typeof user; // { id: number; name: string; role: 'admin' }
三者结合,就能写出「对某个类型的每个键做文章」的类型,这正是工具类型的核心手段。
映射类型:对每个键做变换
映射类型(Mapped Types)用 [K in keyof T] 遍历一个类型的全部键,逐个生成新属性。TypeScript 内置的 Partial 就是最经典的例子:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type PartialUser = MyPartial<User>;
// { id?: number; name?: string; role?: 'admin' | 'user' }
同理可以写出只读版、或修改键类型的版本。as 语法还能在遍历时改写键名:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type G = Getters<User>;
// { getId: () => number; getName: () => string; getRole: () => 'admin' | 'user' }
注意这里 string & K 的写法:keyof T 里可能有 symbol 或 number 类型的键,模板字符串要求是 string,先用交叉类型收窄一下。
条件类型与 infer:类型的 if / else 和模式匹配
条件类型就是「类型层面的三元表达式」:
type IsString<T> = T extends string ? true : false;
type R1 = IsString<'x'>; // true
type R2 = IsString<42>; // false
而 infer 是其中最有魔法的一个关键字:在条件类型的 extends 分支里声明一个「待推断」的占位类型,由 TypeScript 自动推导。最有名的例子是取 Promise 的返回值:
type Awaited<T> = T extends Promise<infer U> ? U : T;
type R = Awaited<Promise<string>>; // string
type R2 = Awaited<Promise<Promise<number>>>; // Promise<number>,递归可解决
infer 的本质是模式匹配——像正则里用捕获组取出一段内容,infer U 就是类型里的捕获组。它配合条件类型能解决大量「从一个复杂类型里挖出某一部分」的需求。
组合实战:Pick / Omit / Exclude
理解了上面四块积木,内置工具类型就不再是黑盒。比如 Pick(选几个键)和 Exclude(从联合类型里剔除):
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
type MyExclude<T, U> = T extends U ? never : T;
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>;
type UserBrief = MyOmit<User, 'role'>;
// { id: number; name: string }
Omit 的实现思路值得细品:先用 Exclude 把要删的键从 keyof T 里剔除,再对剩下的键做 Pick。两行代码,全是用基础动作组合出来的。遇到不认识的工具类型,先试试自己实现一遍,比背文档有效得多。
什么时候该停下来
类型体操写起来上头,但要记住它的成本:复杂类型显著拖慢编辑器的提示响应,也让同事维护时产生理解负担。我的经验法则:
- 能为真实调用方减少错误的类型,值得写。
- 只为了「显得厉害」的类型,别写。
- 一个类型如果连你自己都要想三分钟才能看懂,大概率是过度设计。
实用主义的分界线是:类型服务于使用它的代码,而不是反过来。
小结
类型体操的基本动作就四样:泛型参数化、keyof/typeof/索引访问、映射类型遍历键、条件类型 + infer 做匹配。Partial、Pick、Omit、Awaited 这些日常高频工具类型,全都能用这四个动作重新实现一遍。
建议的练习路径:先把 Partial、Pick、Omit、Exclude 各手写一遍,再试着写一个 DeepReadonly(递归只读),之后面对任何复杂类型都不会再发怵。