← 返回博客列表

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 里可能有 symbolnumber 类型的键,模板字符串要求是 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 做匹配。PartialPickOmitAwaited 这些日常高频工具类型,全都能用这四个动作重新实现一遍。

建议的练习路径:先把 PartialPickOmitExclude 各手写一遍,再试着写一个 DeepReadonly(递归只读),之后面对任何复杂类型都不会再发怵。