文章

类型体操入门实践深度解析

类型体操就是用 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,递归 = 循环,剩下的是练习和肌肉记忆。

本文由作者按照 CC BY 4.0 进行授权