TypeScript 精读 04:条件类型与 infer

第 04 章 条件类型与 infer

代码基线:commit a89bca1316(2026-10-06)| DSH 0.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  
};  

这一行干了两件事:

  1. 剥离每个 action 的第一个参数 draft: T(bake 之后不再需要草稿)
  2. 保留剩下的 ...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。