类型体操听起来像炫技,但它的起点非常朴素:你有一个类型,想要另一个「长得差不多」的类型。
Partial:把所有字段变可选
type User = {
id: string;
name: string;
email: string;
};
type Patch = Partial<User>;Patch 等价于:
type Patch = {
id?: string;
name?: string;
email?: string;
};它的实现本身就是个类型体操,只有一行:
type Partial<T> = {
[K in keyof T]?: T[K];
};拆开看三件事:
keyof T拿到所有键的联合类型[K in ...]遍历这个联合(这叫映射类型)?把每个键变可选
Required 与 Readonly
同一套机制,换个修饰符:
type Required<T> = {
[K in keyof T]-?: T[K];
};
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};-? 是去掉可选修饰符。这个 - 语法很多人第一次见会愣一下。
Record:从键到值
type Page = 'home' | 'archive' | 'tags';
const titles: Record<Page, string> = {
home: '首页',
archive: '历史',
tags: '标签',
};Record 的价值在于穷尽性检查:少写一个键会报错,多写一个也会报错。
条件类型:类型层面的 if
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // falseextends 在这里不是继承,是「能否赋值给」的判断。
infer:把类型里的某部分取出来
这是最容易劝退的一步,但其实只做一件事:在匹配模式时声明一个变量。
type ReturnOf<T> = T extends (...args: never[]) => infer R ? R : never;
type F = ReturnOf<() => string>; // stringinfer R 的意思是:如果 T 是一个返回某物的函数,就把那个返回值类型叫做 R。
真实场景里,这个技巧最常用于解开数组和 Promise:
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type X = Awaited<Promise<Promise<number>>>; // number注意它递归调用了自己——条件类型里写递归是完全合法的。
一个实用的组合
假设你有一组 API 响应类型,想把它们统一包上一层:
type Api<T> = {
data: T;
status: number;
};
type Endpoints = {
user: () => Promise<{ id: string }>;
posts: () => Promise<Array<{ title: string }>>;
};
// 把每个端点的返回值解出来,再包一层
type Wrapped = {
[K in keyof Endpoints]: Endpoints[K] extends (
...args: never[]
) => Promise<infer R>
? Api<R>
: never;
};这段代码不短,但它做到了一件事:接口改了,Wrapped 自动跟着改。这就是类型体操的全部意义。
别过度使用
最后一句提醒:类型体操的成本是可读性。三层嵌套的 infer 能让同事盯着屏幕五分钟。我的经验是:
- 能在两个类型之间手写清楚,就别写递归
- 每个复杂类型都在上面留一行注释说明输入输出
- 类型报错信息如果长得看不懂,说明写得太花哨了