很多人对「类型体操」的印象是炫技。但当你需要给一个内部 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,就能在编译期把路径字符串解析成参数对象——这也是各种「类型安全路由」库的核心思路。
七、几条实用建议
- 类型报错优先看最内层。层层嵌套的条件类型,报错往往指向中间某一步
- 善用
// @ts-expect-error做类型测试:
// @ts-expect-error 参数必须是 string
fn(123);
- 类型复杂到没人看得懂就是负债。公共 API 的类型值得打磨,内部实现看不懂就加注释
satisfies比as安全:它校验类型但保留字面量推断
const config = {
port: 3000,
host: 'localhost',
} satisfies Record<string, string | number>;
config.port.toFixed(); // 仍然知道是 number
小结
类型体操的三块基石其实只有三句话:
- 条件类型是类型层面的
if - 裸类型参数会分发,要连在一起判断就加方括号
infer是类型层面的解构赋值
把这三句吃透,剩下的都是组合。