类型体操入门实践深度解析
类型体操就是用 TS 类型系统写程序:条件类型当 if/else,映射类型当 map,infer 当解构,递归当循环,是 Vue3、Prisma、zod 等库的类型基础设施。 面试重点不在炫技,而在分布式条件类型的分发机制、infer 的提取心法,以及什么时候用中括号包裹关闭分发。
一句话概括
类型体操 = 用 TypeScript 的类型系统写”程序”——条件类型当 if/else,映射类型当 map,infer 当解构,递归类型当 while 循环。它不是炫技,而是 Vue 3、Prisma、zod 等头部项目的类型定义的基础设施。
核心知识点
1. 分布式条件类型——拆开联合,逐个审判
1
2
3
4
5
6
7
8
9
10
11
12
// 裸类型参数触发分发——类型系统的"forEach"
type MyExclude<T, U> = T extends U ? never : T;
type R = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
// 等价于:('a' extends 'a' ? never : 'a') | ('b' extends 'a' ? never : 'b') | ...
// 🔑 想关闭分发?用 [] 包裹——变成"整体比较"
type NoWrap<T> = T extends string ? 'yes' : 'no'; // 分发
type Wrapped<T> = [T] extends [string] ? 'yes' : 'no'; // 不分发
type T1 = NoWrap<'a' | 1>; // 'yes' | 'no' ← 拆开算了
type T2 = Wrapped<'a' | 1>; // 'no' ← 整坨比较
面试杀手题: Exclude<never, string> 返回 never,不是什么,就一个 never。因为 never 是空联合——没有成员可供分发,分布式条件类型直接短路。
2. infer——类型版的解构赋值
1
2
3
4
5
6
7
8
9
10
11
// 提取 Promise 内部值(递归直到非 Promise)
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
type R = Awaited<Promise<Promise<number>>>; // number
// 提取函数签名信息
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
// 提取数组元素(infer 可以出现在任何位置)
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;
infer 的心法: 把 extends 当成模式匹配——左边写模式,右边写输入,infer 捕获匹配到的部分。infer 可以用在同一个 extends 里的多个位置,TS 会在协变位置取联合、逆变位置取交叉。
3. 映射类型 + as 子句——键的过滤与变换
1
2
3
4
5
6
7
8
9
10
11
12
// 给所有 key 加 get 前缀
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
// 按值类型过滤属性(as 子句返回 never 就删除这个 key)
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
interface User { name: string; age: number; active: boolean }
type StringProps = PickByType<User, string>; // { name: string }
as 子句的价值: 它在生成新类型之前先对键做一次过滤/变换。as never 删除键,as NewKey 重命名,as 模板字面量 修改键名。
4. 递归类型——类型系统里的 while(true)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// DeepPartial:深层可选
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends (...args: any[]) => any // 函数跳过!
? T[K]
: DeepPartial<T[K]>
: T[K];
};
// 数组版 flat(用 infer + 递归追平 ES2019 flat(Infinity))
type Flatten<T extends any[]> = T extends [infer First, ...infer Rest]
? First extends any[]
? [...Flatten<First>, ...Flatten<Rest>]
: [First, ...Flatten<Rest>]
: [];
type R = Flatten<[1, [2, [3, 4]]]>; // [1, 2, 3, 4]
⚠️ 递归深度限制: TS 约 50 层就报 “excessively deep”。函数类型、循环引用必须提前终止——这就是为什么 DeepPartial 里单独拦截了函数。
5. 联合转交叉——协变/逆变的实战
1
2
3
4
5
6
7
// 利用函数参数逆变:多个函数联合 → 参数推断为交叉
type UnionToIntersection<U> =
(U extends any ? (arg: U) => void : never) extends
(arg: infer I) => void ? I : never;
type R = UnionToIntersection<{ a: 1 } | { b: 2 }>;
// { a: 1 } & { b: 2 }
原理口诀: 函数参数位置是逆变的——参数能接受的类型越多,函数越”小”(子类型)。多个函数联合时,TS 推断出所有参数的交集。这是类型体操里最”魔法”的套路之一。
其实你每天都在用
- axios 泛型推导:
axios.get<User[]>('/api')→data自动User[],背后是infer+ 条件类型提取 - Vue 3
defineProps: props 被DeepReadonly包裹,子组件改 props 编译就报错 - zustand selector:
useStore(s => s.user.name)返回值类型自动推导——infer + 条件类型 - lodash
_.get类型提示:_.get(obj, 'a.b.c')能补全路径和返回值,靠模板字面量 + 递归类型 - zod schema 类型推导:
z.object({ name: z.string() })的infer直接用条件类型从 schema 反推 TS 类型
常见误解(FAQ)
❌ 误区:「类型体操就是面试造火箭」 Vue 3、Prisma、trpc、zod、React Router 的类型定义全是递归/条件/映射类型的产物。你享受的”自动补全”和”编译报错提前拦截 bug”,90% 来自这些类型体操代码。它不是火箭,它是你每天踩的地砖。
❌ 误区:「
T extends U ? X : Y一定会分发」 只有 T 是裸类型参数时才分发。[T]、T[]、{k: T}都不分发。更隐蔽的是never作为空联合,分发时没有成员 → 直接短路返回never——这也是NonNullable<never>返回never的原因。❌ 误区:「递归类型无限制」 TS 递归深度约 50 层。写
DeepReadonly<AST>时对自引用节点(如parent: AST)不设基本条件,编辑器直接卡死。任何递归类型必须有终止分支(如函数类型、原始类型)。❌ 误区:「infer 只能出现一次」 可以出现多次!
T extends (infer A extends infer B) ? [A, B] : never合法。infer 还能带约束:infer U extends number,TS 4.7+ 支持。
一句话总结
类型体操的心法就一句:把 TS 的类型系统当成一门纯函数式语言——条件 = if,infer = 解构,映射 = map,递归 = 循环,剩下的是练习和肌肉记忆。