《TypeScript 进阶类型:条件类型与 infer》
要点
TS 的类型系统是一门只有”输入输出”、没有运行时的小语言,条件类型和 infer 是它最接近函数式编程的部分:前者给类型写 if/else,后者在模式匹配里抓取未知片段。这篇把四件进阶装备讲透——条件类型与它的”分发”怪癖、infer 的三种常见抓取、模板字面量类型、递归类型——最后划一条该停手的线:类型工具是写给别人读的,炫技型类型在团队代码里是负债。
本文不做:运行时校验(zod 与类型体操是两套互补的系统,本文全是编译期的事)、装饰器、类泛型的 OOP 用法。基础泛型语法不熟的,回TS 语法要点篇补;类型怎么用测试钉住,文末会接到TS 测试入门篇的 expectTypeOf。
条件类型:类型层面的三元表达式
条件类型长这样:T extends U ? X : Y——T 能赋值给 U 就取 X,否则取 Y。最基本的用途是按输入形状做筛选:
type HasId<T> = T extends { id: number } ? T : never;
type A = HasId<{ id: number; name: string }>; // { id: number; name: string }
type B = HasId<{ name: string }>; // never
验证方式:编辑器里悬停 A 和 B 看提示,或者直接 tsc –noEmit 后用 expectTypeOf 钉住(文末给写法)。never 在这里是有用的信号——”不满足形状的类型会被筛掉”,下一节会看到它另一个性格完全不同的用法。
条件类型的日常价值是”收窄标注”:宽类型进、窄类型出。比如一个只接受资源路径的函数,参数类型用 Path = “api” | “static” 这类字面量联合先筛一遍,配合下面的模板字面量,能把”写错的字符串”拦在编译期而不是运行时——这也是它和运行时校验的分界线:条件类型改变的是”编译器允许你写什么”,改变不了数据本身。
分布式条件类型:裸参数的隐藏行为
条件类型最著名的坑来了:当类型参数是”裸的”(直接参与 extends 判断),传入联合类型时会按成员逐个分发,结果再联合起来:
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>;
// 不是 (string | number)[],而是 string[] | number[]
type Wrapped<T> = [T] extends [unknown] ? T[] : never;
type B = Wrapped<string | number>; // (string | number)[],用元组包住阻止分发
分发还解释了 never 的诡异行为:never 是空联合,分发时一个分支都没有,整个结果直接是 never:
type NonNull<T> = T extends null | undefined ? never : T;
type C = NonNull<string | null>; // string——null 分支被分发成 never,自动消失
type D = ToArray<never>; // never,不是 never[]!
这两条合起来其实就是内置工具 Exclude 的实现原理:T extends U ? never : T。记不住规则的时候,想想”裸参数按成员分发、元组包裹整体判断”这两句话就够了。
type T1 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
type T2 = Extract<"a" | "b" | number, string>; // "a" | "b"
分发还有个正经用途:穷尽性检查。联合类型的 switch 少写一个分支时,把 default 分支赋给 never:
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; s: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.s ** 2;
default: {
const _exhaustive: never = s; // 新增 kind 而漏改这里时,编译期直接报错
return _exhaustive;
}
}
}
这是全项目性价比最高的 never 用法:新增联合成员时,编译器替你找出所有要改的 switch。数学类型、事件类型、状态机,都值得配上这一手。
infer:在模式里抓未知
infer 只能出现在 extends 子句右侧,作用是”把匹配到的未知片段抓出来当变量”。三种最常见的抓法——数组元素、函数返回值、Promise 内层:
type ElementType<T> = T extends (infer U)[] ? U : never;
type E = ElementType<Promise<string>[]>; // Promise<string>
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
type F = MyReturnType<() => number>; // number
type Unwrap<T> = T extends Promise<infer V> ? Unwrap<V> : T;
type G = Unwrap<Promise<Promise<string>>>; // string(与内置 Awaited 同思路)
要拿函数第一个参数,把 infer 放在参数位置。内置的 ReturnType、Parameters 全是这套语法的官方封装——学 infer 的另一个好处,是遇到内置覆盖不到的古怪形状时能自己写:
type FirstParam<T> = T extends (p: infer P, ...rest: never[]) => unknown ? P : never;
type H = FirstParam<(url: string, init?: RequestInit) => void>; // string
一个容易忽略的语义:同一段模式里有多个 infer 时,协变位置(返回值、数组元素)的多个 infer 会推成联合,逆变位置(函数参数)会推成交叉。大多数时候你只放一个 infer,这个规则碰不到;碰到时先怀疑是不是模式写宽了。infer 抓参数元组整体也常用:
type Args<T> = T extends (...args: infer A) => unknown ? A : never;
type I = Args<(a: string, b: number) => void>; // [a: string, b: number]
模板字面量类型:字符串也参与类型运算
模板字面量类型让字符串字面量之间做拼接与变换,配上映射类型的 as 重映射,可以机械地生成成对的类型:
type EventName = `on${Capitalize<"click" | "focus">}`;
// "onClick" | "onFocus"
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }
Getters 的读法:对 T 的每个键 K,as 右边把它重映射成 get + 首字母大写的新键名,值类型取原字段 T[K]。K & string 是必须的——keyof T 的结果可能是 symbol,Capitalize 不接受,交叉一下收窄成 string。这类”成对生成”最典型的落地是事件系统:事件名到监听器名的映射,写一处,两端类型永远同步。
模板字面量还能反向解析,从字符串模式里抓片段:
type RouteParams<R> = R extends `/${infer A}/${infer B}` ? [A, B] : never;
type P = RouteParams<"/users/42">; // ["users", "42"]
大小写变换用内置的 Uppercase / Lowercase / Capitalize / Uncapitalize,它们是编译期内建,不是运行时函数——只能在类型位置用。模板字面量还适合给参数上约束:type Path = “/api/${string}” 这样的类型做函数参数,任何不以 /api/ 开头的字符串在编译期就被拦下,比运行时 if 检查省事。
递归类型与它的深度上限
条件类型可以引用自己,于是有了递归类型。两个常用的定义:
type DeepReadonly<T> = T extends Function
? T
: { readonly [K in keyof T]: DeepReadonly<T[K]> };
type DeepAwaited<T> = T extends Promise<infer V> ? DeepAwaited<V> : T;
递归有代价:TS 对类型实例化的深度和数量有上限,超了报 ts2589——”Type instantiation is excessively deep and possibly infinite”。常见的触发方式是自引用结构套递归工具、或者超大联合类型进 DeepReadonly。我的实用线是:嵌套超过五六层的结构,别指望一个递归类型全覆盖,手写展开关键路径反而可靠;递归类型用在”形状规则明确”的数据(配置对象、API 响应)上,别套在任意 JSON 上。数组字段的递归也值得一提:DeepReadonly 对数组元素同样生效,产出只读数组,配置对象里有列表字段时这是想要的,但函数属性会被一并 readonly 化——所以定义里先把 Function 排除掉,类实例属性别进这个工具。
何时该停手:可读性边界
类型体操的边际收益跌得很快,四条判断标准,按顺序过:
- 报错可读性:复杂类型出错时,TS 给的错误动辄几十行。如果团队没人读得懂这些报错,这个类型的收益是负的;
- 数据来自边界就别推:接口返回的数据直接标 DTO 类型加运行时校验,别用类型推导去”算”——推出来的形状来自你的假设,不是真实数据;
- 分层放置:条件类型、infer、递归集中写在工具库层,一次写好、注释齐全;业务代码用平铺类型直接标注;
- 三十秒原则:一个类型定义如果我不能在三十秒内向同事解释清楚它产出什么,就重写成两三个简单类型,或者干脆用函数重载。
一个我实际做过的重构:事件处理器签名原来是一条推导链,从事件名反推负载类型,全组只有作者看得懂。改成”负载表驱动”后功能一致,可读性完全不同:
// 之前:从字符串反推负载,链路藏在类型里
type Handler<E extends string> = E extends `on${infer N}`
? (p: PayloadFor<Lowercase<N>>) => void : never;
// 之后:负载表即配置,映射类型一步生成
type PayloadMap = { click: ClickPayload; focus: FocusPayload };
type Handlers<M extends PayloadMap> = {
[K in keyof M as `on${Capitalize<K & string>}`]: (p: M[K]) => void;
};
区别不在长短,在”改需求时动哪”:之前要顺着推导链找,之后改 PayloadMap 一张表。
内置工具类型对照:能用内置就不手写
- ReturnType / Parameters:函数返回值与参数元组,都是 infer 的官方封装;
- Awaited:递归解 Promise(含 thenable),比手写 Unwrap 覆盖广;
- Exclude / Extract:分发式筛除与筛选;
- NonNullable:剔掉 null 与 undefined;
- Readonly / Partial / Pick / Omit:映射类型基本款,DeepReadonly 就是给 Readonly 加一层递归。
与 as 的分工提一句:强行改类型用 as,对象字面量”既要检查又不丢推导”用 satisfies——后者在配置对象上几乎零成本,写配置时顺手就用上了。这不是类型体操,是让编译器替你查配置的拼写错误。
顺带一句 5.8 的新东西:这版自身的特性(条件 return 分支里更细的检查、require() 加载 ESM)与本文主题关系不大,我不掺进来凑数;文中全部写法在 4.x 时代就已可用,5.8 只是当时的当前版本线。
常见坑
- 裸参数被意外分发:想整体判断却按分支逐个判断——用 [T] 包住,或改用布尔辅助(T extends X ? true : false 先收窄)。
- never 在分发中消失:ToArray 产出 never,下游”必须有元素”的约束被静默放空。
- string 吞掉字面量:T extends string 对字面量联合恒真,想区分具体字面量得用模板字面量模式或先收窄。
- infer 放错位置:只能在 extends 右侧;放别处直接编译错。
- 递归深度爆掉:ts2589 多半来自递归工具套自引用结构或超大联合,先收窄输入再套工具。
- 过度泛化:为”可能复用”写四个泛型参数的工具类型,全项目只有一处调用——删掉,直接写具体类型。
- 给类实例做深层工具:DeepReadonly 套到带方法的实例上,方法签名也被 readonly 改写,调用报错——带行为的对象不进深层工具,只处理纯数据结构。
- 条件类型当运行时逻辑写:它决定”编译器允许什么”,做不了默认值、做不了转换——需要转换的还是运行时代码。
验收清单
- ElementType、DeepReadonly、Getters 三个工具类型各配一条 expectTypeOf 用例:
import { expectTypeOf } from 'vitest';
test('Getters 形状', () => {
expectTypeOf<UserGetters>().toEqualTypeOf<{
getName: () => string;
getAge: () => number;
}>();
});
- DeepReadonly 应用到一个真实 config 对象,给只读字段赋值时报错,tsc –noEmit 全绿。
- 至少一处事件或状态联合配了 never 穷尽性检查:删掉一个 case,tsc 即红。
- 每个手写的工具类型都与内置等价物核对过(ReturnType、Parameters、Awaited、Exclude),能用内置就不手写。
- 每个复杂类型头部有两行注释:输入示例到输出示例。
- 全项目搜索递归类型与裸分发写法,集中在工具库且有注释,业务代码里为零。
把 expectTypeOf 用起来的完整套路(配置、异步、类型断言的组织)在TS 测试入门篇;本文用到的映射类型与 keyof 基础,回TS 语法要点篇对应条目。
本文数据核对时间:2025-04-28:TypeScript 5.8(2025-03 正式发布,官方发布公告口径);文中条件类型、infer、模板字面量类型、递归类型均为 4.x 起可用的稳定特性,与 5.8 新特性无耦合,行为以 TypeScript 官方文档当日版本为准。