第 04 章 条件类型与 infer
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T3
本章导读
这一章讲 TS 里"像程序一样写类型"的那一半。
前三章的积木——keyof、typeof、T[K]——都只能查询。条件类型让类型开始
判断,infer 让它开始提取。有了这两件,类型才从"描述一个形状"升级成
"描述一个规则"。
DSH 全仓有 142 处 infer、27 处递归条件类型。有意思的是它把类型体操
做成了一个产品——[packages/typert/``packages/typert 是 DSH 自家的
类型图库。所以这些不是炫技,是刚需:Typert 要在运行时重建一个类型在
编译期的形状。
本章的三个锚点全部来自一份真实文件
[packages/extensions/cordis-client-runner/src/client/api-catalog.ts``packages/extensions/cordis-client-runner/src/client/api-catalog.ts,
那里存着 DSH 公开 API 的声明原文:
| 声明 | 演示什么 |
|---|---|
ChainKeysOf |
分布式条件类型,以及它最著名的陷阱 |
BakedActions |
infer 提取 rest 参数元组 |
BoundActions |
一个模式里两个 infer |
读完本章你要能回答:"为什么 DSH 写 S extends unknown ? 而不是直接写
S extends ... ??" 答案是一个能让你 debug 半小时的 never 陷阱。
一、条件类型:一个在类型层面写的三元表达式
type IsString<T> = T extends string ? true : false
type A = IsString<'x'> // true
type B = IsString<number> // false
结构上它就是三元运算符的泛化:一个待判断的泛型位置 + 两个分支。
第 03 章的 WebhookEventOf<K> 已经是条件类型了:
K extends keyof WebhookEventMap ? WebhookEventMap[K] : JsonValue
本章要补的是它没有说出来的部分:当 K 是一个联合时,这个 extends
到底判断几次。
二、分布:条件类型会偷偷展开联合
2.1 锚点
[api-catalog.ts:508``packages/extensions/cordis-client-runner/src/client/api-catalog.ts:508
存着这条声明原文:
export type ChainKeysOf<S extends keyof SlotMap & string> =
S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never
注意那个看似多余的 S extends unknown ?。它不是废话,而是唯一的手段。
2.2 实测:它会分发
type Dist<A> = A extends unknown ? 'yes' : 'no'
type d1: Dist<'a' | 'b'> = 'yes' // ✓ 通过
如果 Dist 不分发,'a' | 'b' 整体去匹配 unknown 会得到 A extends unknown
——string extends unknown 成立,所以结果也是 'yes'。两种情况在这里
看不出差别。 需要一个能分辨的输入。
2.3 陷阱:对 never 分发
type d3: Dist<never> = 'yes'
实测:
error TS2322: Type '"yes"' is not assignable to type 'never'.
Dist<never> 等于 never。
原因:分发意味着"把联合的每个成员单独送进条件判断"。never 是空联合——
它没有任何成员。于是"每个成员的结果取并集"就是空集合的并集 = never。
记忆方式:Dist<never> = never,NoDist<never> = 走 else 之外的正常判断。
type NoDist<A> = [A] extends [unknown] ? 'yes' : 'no'
type d4: NoDist<never> = 'yes' // ✓ 通过
方括号 [A] extends [A] 技巧关闭分发,因为 [never] 是一个真实的元组
类型,与 [unknown] 可比较。
本章第一条纪律
写一个"过滤联合成员"的条件类型时(返回
never剔除),必须显式分发,
也就是T extends unknown ? ... : never。
这是ChainKeysOf、Exclude、Extract的共同写法,也是它们的共同陷阱。
而只想要"整体判断一次"时(NonNullable、所有extends在右边的场景),
要用[T] extends [T]关闭分发。
2.4 DSH 里这两种写法并存
把 DSH 的几条声明并排看,分发与不分发是刻意的:
// 分发:逐个成员判断,剔除非 chain 的键
type ChainKeysOf<S> = S extends unknown ? (SlotMap[S]['kind'] extends 'chain' ? S : never) : never
// 不分发:整个联合一次性判断
type NonNullable<T> = T extends null | undefined ? never : T
区别在于 null | undefined 写在右边。T extends null | undefined 判断的是
"T 整体是不是 null 或 undefined",而 T 在左边会触发分发——对
string | null 分发两次,string 通过、null 落进 never 分支,结果恰好
也正确。两个写法在 NonNullable 上等价,但语义不同。
这是一个很容易凭直觉判断错的地方:第 03 章说"四个工具类型是同一行代码的
变体",这里补一句——它们连分发行为都不一定相同。
三、infer:从判断升级为提取
条件类型只能判断"像不像"。infer 让你在判断的同时把匹配到的部分取出来。
语法上只有一个变化——在被匹配的位置写 infer X:
type Elem<T> = T extends (infer U)[] ? U : never
type A = Elem<string[]> // string
type B = Elem<number> // never
(infer U)[] 的意思是"一个数组,它的元素类型我暂时叫 U"。判断通过后,
U 就被具体化了。
3.1 锚点一:提取 rest 参数元组
[api-catalog.ts:492``packages/extensions/cordis-client-runner/src/client/api-catalog.ts:492:
export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
export type BakedActions<T, A extends ActionsDecl<T>> = {
[K in keyof A]:
A[K] extends (draft: T, ...params: infer P) => void
? (...params: P) => void
: never
};
这一行干了两件事:
- 剥离每个 action 的第一个参数
draft: T(bake 之后不再需要草稿) - 保留剩下的
...params,用infer P把这个元组抓出来,再原样展开回去
实测(Acts = { setName: (draft: string, name: string) => void; clear: (draft: string) => void }):
type B = Baked<string, Acts>
const b1: B = { setName: (n: string) => {}, clear: () => {} } // ✓ 通过
const b2: (name: string) => void = null as unknown as B['setName'] // ✓ 通过
b1 通过说明 setName 的 draft 被剥掉了、只剩 name: string;b2 进一步
确认提取出来的就是元组而不是 any[]。
这一行的价值在于:它让一个类型安全的"剥参数"操作可以被编译器验证。 映射
类型(下章主角)负责遍历,infer 负责提取,条件类型负责判断。三块积木
第一次同时出现在一行里。
本章第二条纪律
看到...args: infer P就知道:P抓的是一个元组类型,
后面必须用...P展开才能当参数列表用。写成P是最常见的错误。
3.2 锚点二:一个模式里两个 infer
[api-catalog.ts:500``packages/extensions/cordis-client-runner/src/client/api-catalog.ts:500:
export type BoundActions<H> =
H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
一个模式 StoreHandle<...> 里写了两个 infer,分别把第一个和第二个类型实参
提取成 T 和 A,然后喂给上一节的 BakedActions。
实测:
type StoreHandle<T, A> = { t: T; a: A }
const s1: [string, ActionsDecl<string>] =
null as unknown as BoundActions<StoreHandle<string, ActionsDecl<string>>> // ✓ 通过
const s2: never = null as unknown as BoundActions<number> // ✓ 通过
s2 说明模式不匹配时老实走 never 分支,不会给出半提取的垃圾。
规则很简单:infer 的个数必须与模式里的类型参数个数一致,多一个少一个
都是语法错误。名字可以随便起,A B C 都行。
3.3 一个不那么显然的陷阱:约束必须长在模式上
把上面那条声明的依赖展开看:
type BakedActions<T, A extends ActionsDecl<T>> = { ... }
type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
BakedActions 的第二个类型参数有约束。而从模式里 infer 出来的 A
不会自动满足它。实测:
error TS2344: Type 'A' does not satisfy the constraint 'ActionsDecl<T>'.
那 DSH 这一行为什么能编译?看它的模式类型
[packages/client/store/src/contract.ts:93``packages/client/store/src/contract.ts:93:
export interface StoreHandle<T, A extends ActionsDecl<T>> { ... }
StoreHandle 自己的 A 就带着同一个约束。 于是当 infer A 匹配
StoreHandle<...> 时,编译器已经通过这个约束证明了 A extends ActionsDecl<T>,
BakedActions<T, A> 才被接受。
把约束从模式上拿掉,同一行立刻报 TS2344(实验第 3 组第三条断言)。
这不是巧合,是设计。 约束写在模式类型上,等于把这个类型放进系统时就
声明了"第二个实参必然是合法 actions 声明";之后任何地方用 infer 把它取出来,
证明自动随行。反过来,如果只有消费方有约束、提供方没有,提取出来的东西
就是一张欠条。
本章第三条纪律
用
infer提取的类型实参,不会自动满足目标类型参数的约束。
要让它通过,靠的不是在消费方加约束,而是在模式类型上就写好约束——
让证明随类型一起传递。消费方单独加约束只会在编译期炸。
3.4 约束版 infer
infer 后面还能加约束:
type Str<T> = T extends `${infer S extends string}` ? S : never
这在第 05 章讲模板字面量时会大量使用——infer S extends string 比
infer S 多一道保证:被捕获的那一段至少是个字符串。不做这层保证的话,
后续拿 S 去调字符串方法会报错。
四、递归条件类型
当条件类型引用自己时,它就成了类型层面的递归函数。第 03 章说
MyReturnType 用了 infer;这里给一个真正递归的例子:
type DeepReadonly<T> =
T extends (infer U)[] ? DeepReadonly<U>[] :
T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
T
递归的终止条件是最后一行的 T(原始类型不是数组也不是对象时直接返回自己)。
没有终止条件 = 编译期无限深 = 报错。
DSH 里有 27 处这种递归,绝大多数集中在 packages/typert——它要在运行时
重建嵌套的类型结构。
本章第三条纪律
写递归条件类型时,先写终止分支。
T extends ... ? Deep<T> ... : T
这个模式是标准形状;把T放在else里(而不是never),
递归才有底。
五、never 的双重身份
本章出现了三次 never,它值得单独一节,因为它的两种用法含义完全不同。
| 位置 | 含义 |
|---|---|
T extends U ? never : T(Exclude) |
剔除——"这个成员不要了" |
T extends U ? T : never(Extract) |
保留——"只要符合条件的" |
Dist<never> |
空集合——"没有成员可判断" |
第一种和第二种里的 never 是占位符,它会在联合里消失。
第三种的 never 是真的空。
把这个混淆搞错,就会写出 2.3 节那种 bug:你以为在剔除成员,结果整个类型
塌成 never,而报错信息只有一句 Type '"yes"' is not assignable to type 'never'。
本章第四条纪律
见到
never先问一句:它在一个联合里(会消失),还是就是整个类型(会塌陷)?
排查塌陷时,先把条件类型包成[T] extends [T]关掉分发试试。
六、本章小结
- 条件类型是类型层面的三元表达式。
T extends U ? A : B。 - 分发:
T extends U ?在T是联合时会逐个成员判断。
需要分发就写T extends unknown ?;需要整体判断就写[T] extends [T]。 Dist<never>=never。对空联合分发得到空联合。这是本章最贵的陷阱。infer让条件类型同时完成提取。...args: infer P抓到的是元组,
用的时候必须...P展开。- 一个模式里可以有多个
infer,个数必须与模式类型参数个数一致;
不匹配时老实走never分支。 infer提取的类型实参不自动满足目标约束(TS2344)。要让证明随类型传递,
约束必须写在模式类型上——DSH 的StoreHandle<T, A extends ActionsDecl<T>>
正是这样。- 递归条件类型必须先写终止分支,把
T放在else里。 never有两种身份:联合里会消失的占位符,和会塌陷整个类型的空集合。
下一章讲映射类型与模板字面量——BakedActions 里那行 [K in keyof A] 只用了
半条,留下的另一半是把"键集合"变成"字符串格式的协议",那是把 DSH 的 51 个
事件名变成类型的地方。
本章实验(附录)
实验脚本:[labs/M4-infer-lab.tslabs/M4-infer-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M4-infer-lab.ts"
这个实验做什么:本章五条论断——尤其是 Dist<never> 与 NoDist<never>
的分歧——全部改用赋值法判定(第 03 章 L-05 的教训)。三条锚点声明原样搬进
探针,证明它们在今天的编译器上仍然成立。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:分发的两个方向
✓ 显式分发对联合逐成员判断
✓ 方括号技巧关闭分发
✓ Dist<never> 塌陷为 never(本系列最贵的陷阱)
✓ NoDist<never> 不塌陷
── 组 2:infer 提取 rest 元组
✓ BakedActions 剥掉 draft 后保留 name
✓ 提取出的是元组而非 any[]
✓ 无 rest 参数的 action 剥成空元组
── 组 3:一个模式两个 infer
✓ BoundActions 提取两个类型实参
✓ 模式不匹配时走 never 分支
✓ 模式类型去掉约束后,同一行报 TS2344
── 组 4:递归条件类型
✓ DeepReadonly 终止于原始类型
✓ 递归穿透数组与对象两层
── 组 5:真实声明仍然成立
✓ ChainKeysOf 的显式分发写法可编译
✓ ChainKeysOf 正确剔除非 chain 的键
✓ BakedActions 与 BoundActions 可编译并满足上述行为
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。