合者离之始
Record:004

TypeScript 类型体操:从 Partial 到 infer

不背 API,从「为什么需要」出发,把 Partial、Record、条件类型和 infer 串成一条线。

Posted2026-06-30Words1635Reading4 min

类型体操听起来像炫技,但它的起点非常朴素:你有一个类型,想要另一个「长得差不多」的类型。

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>;      // false

extends 在这里不是继承,是「能否赋值给」的判断。

infer:把类型里的某部分取出来

这是最容易劝退的一步,但其实只做一件事:在匹配模式时声明一个变量。

type ReturnOf<T> = T extends (...args: never[]) => infer R ? R : never;
 
type F = ReturnOf<() => string>; // string

infer 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 能让同事盯着屏幕五分钟。我的经验是:

  • 能在两个类型之间手写清楚,就别写递归
  • 每个复杂类型都在上面留一行注释说明输入输出
  • 类型报错信息如果长得看不懂,说明写得太花哨了