很多人对「类型体操」的印象是炫技。但当你需要给一个内部 SDK 写类型、或者读不懂 zod、trpc 的类型报错时,这些能力就是刚需。

一、条件类型是类型层面的 if

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

关键在 extends 不是「继承」,而是「可赋值给」。

二、裸类型参数会分发

这是最容易踩的坑:

type ToArray<T> = T extends any ? T[] : never;

type R1 = ToArray<string | number>;
// 结果不是 (string | number)[],而是 string[] | number[]

因为 T 是裸类型参数,联合类型会被拆开逐个判断,最后把结果再合并成联合。

想关闭分发,用方括号包一层:

type ToArrayNoDist<T> = [T] extends [any] ? T[] : never;
type R2 = ToArrayNoDist<string | number>;  // (string | number)[]

实战意义:写 Exclude<T, U> 这类工具类型时,分发是我们要的;写「判断 T 是不是联合类型」时,分发是我们要躲的。

三、infer 是类型里的解构

type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type X = ReturnOf<() => number>;        // number
type Y = Unwrap<Promise<string>>;       // string

infer 的位置决定「从哪儿取值」。可以一次解构多个:

type Fn<P, R> = (arg: P) => R;

type Split<T> = T extends (arg: infer P) => infer R ? [P, R] : never;
type S = Split<Fn<{ id: number }, string>>;  // [{ id: number }, string]

四、动手实现一遍内置工具类型

理解了上面两点,Pick / Omit / Record 就不神秘了:

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type MyOmit<T, K extends keyof any> = MyPick<T, Exclude<keyof T, K>>;

type MyRecord<K extends keyof any, V> = {
  [P in K]: V;
};

type MyExclude<T, U> = T extends U ? never : T;
type MyNonNullable<T> = T extends null | undefined ? never : T;

MyExclude 用到了分发:T 是联合会被拆开,落在 U 里的分支变成 never,而 never 在联合中会被自动吸收。

type T = MyExclude<'a' | 'b' | 'c', 'a'>;
// 'a' → never, 'b' → 'b', 'c' → 'c'
// never | 'b' | 'c'  →  'b' | 'c'

五、把对象类型变成联合

想给「配置对象」推算出所有合法 key 的联合:

const routes = {
  home: '/',
  post: '/posts/:slug',
  tag: '/tags/:slug',
} as const;

type RouteName = keyof typeof routes;             // 'home' | 'post' | 'tag'
type RoutePath = (typeof routes)[RouteName];      // '/' | '/posts/:slug' | '/tags/:slug'

反向操作也常用——把联合变成对象:

type EventMap = {
  click: { x: number; y: number };
  keydown: { key: string };
};

type Handler = {
  [K in keyof EventMap]: (payload: EventMap[K]) => void;
};

// 等价于
// { click: (payload: {x:number;y:number}) => void; keydown: ... }

六、一个实战例子:类型安全的路由参数

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
    : Path extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type P1 = ExtractParams<'/posts/:slug'>;           // { slug: string }
type P2 = ExtractParams<'/u/:uid/p/:pid'>;         // { uid: string; pid: string }

模板字面量类型配合 infer,就能在编译期把路径字符串解析成参数对象——这也是各种「类型安全路由」库的核心思路。

七、几条实用建议

  1. 类型报错优先看最内层。层层嵌套的条件类型,报错往往指向中间某一步
  2. 善用 // @ts-expect-error 做类型测试:
// @ts-expect-error 参数必须是 string
fn(123);
  1. 类型复杂到没人看得懂就是负债。公共 API 的类型值得打磨,内部实现看不懂就加注释
  2. satisfies 比 as 安全:它校验类型但保留字面量推断
const config = {
  port: 3000,
  host: 'localhost',
} satisfies Record<string, string | number>;

config.port.toFixed();   // 仍然知道是 number

小结

类型体操的三块基石其实只有三句话:

  • 条件类型是类型层面的 if
  • 裸类型参数会分发,要连在一起判断就加方括号
  • infer 是类型层面的解构赋值

把这三句吃透,剩下的都是组合。