第 03 章 索引与查询
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T2
本章导读
这一章讲三个操作符:keyof、typeof、T[K]。
它们看起来不起眼,却是后面两章(条件类型与 infer、映射类型与模板字面量)
唯一的积木。infer 做的事本质上就是"在类型里查一次键",映射类型做的事
本质上就是"把一个键集合逐个查一遍"。不懂这一章,第 04、05 章就是空中楼阁。
DSH 全仓有 227 处 keyof typeof,却没有一处解释它。更妙的是,它在
[packages/webhook/webhook/src/types.ts``packages/webhook/webhook/src/types.ts
里把三个操作符串成了一句自然语言,而且从第 7 行开始的那个东西是一个空接口——
这会逼出本章最反直觉的两个实测结果。
读完本章你要能回答:"为什么 WebhookEventOf 非写条件类型不可?"
答案落在两个具体错误码上:TS2537 与 TS2536。
一、锚点:三行代码,一个空接口
// packages/webhook/webhook/src/types.ts
/** Provider adapters add their normalized event type through declaration merging. */
export interface WebhookEventMap {}
/** Event value for a known provider kind, or generic lossless JSON for an out-of-tree kind. */
export type WebhookEventOf<K extends string> =
K extends keyof WebhookEventMap ? WebhookEventMap[K] : JsonValue
十行不到,四个概念全在里面:
| 行 | 用了什么 |
|---|---|
| 第 7 行 | 空接口——keyof 会返回什么? |
| 第 10 行 | K extends string——类型参数加约束(第 02 章) |
| 第 11 行 | K extends keyof WebhookEventMap——条件类型(第 04 章) |
| 第 11 行 | WebhookEventMap[K]——索引访问,本章主角 |
| 第 11 行 | : JsonValue——回退分支,本章要论证的必要性 |
第 6 行的注释还埋了一个伏笔:"Provider adapters add their normalized event type
through declaration merging"——这个空接口是等着被外部插件用声明合并填满的。
那是第 07 章的主题。本章只关心一件事:当它还是空的、或者被填满之后,
keyof 与索引访问分别会发生什么。
二、keyof:把类型压成一组键的联合
keyof T 的结果是 T 所有合法键名构成的联合类型。
2.1 空对象的 keyof 是 never
实测(赋值法判定,详见实验第 1 组):
type Empty = {}
const e: never = null as unknown as keyof Empty // ✓ 通过
通过的原因是:never 可以赋给任何类型。如果 keyof {} 是别的什么,
这一行就会报错。 它通过了,所以 keyof {} 就是 never。
这条规则解释了第 7 行那个空接口的初始状态:一个 provider 插件都还没接入时,
keyof WebhookEventMap 是 never,于是
K extends never ? ... : JsonValue
对任何 K 都走 else 分支,全部退化成 JsonValue。空表安全地退化成
"无类型信息",这是设计上的好性质:未接入的 provider 得到宽泛的 JSON,
而不是一个假的精确类型。
2.2 keyof 在联合与交叉上行为相反
这是本章最值得记住的一条实测:
| 表达式 | 结果 |
|---|---|
keyof ({ a: 1; b: 2 } | { c: 3 }) |
never |
keyof ({ a: 1 } & { b: 2 }) |
"a" | "b" |
两行都是实测确认的。原因是:
- 联合:键要同时存在于每一个成员上才算数。
a、b不在{c: 3}上,
c不在{a,b}上,交集为空。 - 交叉:键只要在任意一个成员上就有。
a和b各来一个,并起来。
记忆方式:联合取交集,交叉取并集。
这个差异在第 05 章写映射类型时会再次咬人——keyof (A & B) 是键的并集,
于是遍历它得到的是两个类型各自的键混在一起,而你可能期望 A 的键配上 B 的值。
2.3 typeof:从值取类型
typeof 是第 01 章讲过的运行时操作符在类型位置的用法:
const MODEL_STORE = { 'gpt-4o': {...}, 'deepseek': {...} } as const
type ModelName = keyof typeof MODEL_STORE // 模型名的联合,不用手写
keyof typeof X 这个组合是 DSH 里 227 处的来源。最实战的一例在
[packages/experimental/webworker-runtime/.../implemented/fs.ts:840``packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts:840:
} satisfies Partial<Record<keyof typeof import('node:fs/promises'), unknown>>
意思是:"我这份 shim 允许缺字段,但字段名必须来自 Node 官方 fs 模块的真实导出"。
写 shim 时最容易犯的错是拼错一个 API 名,而 keyof typeof import(...) 让
Node 版本一变,写错就立刻报错。satisfies 保证了不匹配的键进不来(详见第 06 章)。
本章第一条纪律
keyof typeof someConst是"让编译器替你维护一个联合类型"的免费午餐。
只要你手写了一份字面量联合,就该问一句能不能用这个替掉。
三、T[K]:索引访问,以及它的硬性前提
T[K] 表示"取出 T 里键为 K 的那个属性的类型"。
但它有一个硬性前提:K 必须是 T 的合法键。 这不是建议,是编译器强制。
实测:
type Map = { x: number; y: string }
type Bad = Map[string]
error TS2537: Type 'Map' has no matching index signature for type 'string'.
TS2537 就是本章存在的理由。 回到第 10–11 行:
export type WebhookEventOf<K extends string> =
K extends keyof WebhookEventMap ? WebhookEventMap[K] : JsonValue
如果直接写 WebhookEventMap[K](K extends string),会立刻报错。原因是
string 不是 keyof WebhookEventMap 的子类型——string 表示任意字符串,
而 WebhookEventMap 只有有限几个已知键(一个合法的子集)。
一个必须分清的细节:这里有两个不同的诊断码。
写法 诊断 Map[string](具体string做索引)TS2537 has no matching index signatureM[K](K extends string做索引)TS2536 Type 'K' cannot be used to index type 'M'本系列首版把真实代码那行也说成 TS2537,是错的(勘误 E-06)。两者含义相同
("这个键不合法"),但排查时要对得上号。
所以那行条件类型做了一件精确的事:
"只有当
K确实是一个已知的事件名时,才去查它的载荷类型;否则退化成
JsonValue。"
这正是第 11 行注释说的:"Event value for a known provider kind, or generic
lossless JSON for an out-of-tree kind."
本章第二条纪律
T[K]报 TS2537 时,不要去给T加索引签名来"修好"它。
那是在掩盖问题:你真正想说的是"键未知时不要索引"。
条件类型才是正确的表达。
3.1 两种安全的索引写法
type Map = { x: number; y: string }
type ValueOf<K extends keyof Map> = Map[K] // 写法一:约束 K(推荐)
type ValueOf2<T, K extends keyof T> = T[K] // 写法二:约束 T
写法一正是 WebhookEventOf 的形状。两种都合法,区别只在你要约束哪一个
类型参数。第 02 章说过"约束的职责是划定合法取值范围",这里就是它的典型用途。
四、手工推导四个内置工具类型
现在把前面三件积木拼起来。TypeScript 内置的 lib.es5.d.ts 里有一批工具类型,
它们没有一个用了魔法——全部是 keyof + 索引访问 + 条件类型的组合。
4.1 Exclude:剔除非成员
实测:Exclude<keyof Map, 'x'> = 'y'(第 1 组断言)。
展开后就是:
type MyExclude<T, U> = T extends U ? never : T
对每个 T 的成员:如果它也是 U 的成员,产 never;否则原样返回。
never 从联合里消失,于是等于"剔除"。
注意条件类型这里是"分发"的:写在 T 位置的 extends 会对联合的每个成员
单独判断一次。这叫分布式条件类型,第 04 章会专门处理它的陷阱。
4.2 Extract:反向
type MyExtract<T, U> = T extends U ? T : never
同一行代码,把两个分支对调。四个工具类型里有一半是同一句话的变体。
4.3 NonNullable
type MyNonNullable<T> = T extends null | undefined ? never : T
注意这里 null | undefined 写在右边(U 的位置),所以不发生分发——
T 整体判断一次。分发的有无是这一章内容在第 04 章的直接延伸。
4.4 ReturnType
type MyReturnType<F extends (...args: never[]) => unknown> =
F extends (...args: never[]) => infer R ? R : never
这一行需要 infer,是第 04 章的内容。但注意它的外壳:F extends (...args: never[]) => unknown 是一个条件类型,keyof 根本没用上。
本章第三条纪律
工具类型不是"高级语法",它们是
keyof/T[K]/ 条件类型 /
infer这四块积木的有限种拼法。背内置库不如会拆它。
五、索引签名与 noUncheckedIndexedAccess
最后一节,讲一个从第 01 章延续下来的话题:读一个可能不存在的键会怎样。
type Dict = { [k: string]: number }
const d: Dict = { a: 1 }
const v = d.anything // 类型是 number
索引签名 [k: string]: number 声明"任何字符串键都有一个 number 值"——
这是断言,不是承诺。d.anything 运行时可能是 undefined。
[tsconfig.base.json``tsconfig.base.json 开着的
"noUncheckedIndexedAccess": true 就是治这个:
// 开启后
const v = d.anything // 类型是 number | undefined
第 01 章已经实测过它把诊断变成 TS2532。这里补一条它在索引签名上的意义:
| 配置 | d.anything 的类型 |
后果 |
|---|---|---|
| 关闭 | number |
看起来永远有值,实际可能是 undefined |
| 开启 | number | undefined |
强制你处理缺失 |
本章第四条纪律
写
Record<string, T>或索引签名时,必须确认
noUncheckedIndexedAccess是开的,否则你在一份"必填"的假象上写代码。
DSH 开着。这是它 1115 处 node: 导入能不出乱子的前提之一。
六、伏笔:一个等着被填满的空接口
回到第 7 行:
/** Provider adapters add their normalized event type through declaration merging. */
export interface WebhookEventMap {}
一个空接口,注释说它会被 provider 适配器用声明合并填满。也就是说,
一个外部包可以写:
declare module '@deepseek-ai/dsh-webhook' {
interface WebhookEventMap {
github: GitHubPushEvent
}
}
然后 keyof WebhookEventMap 从 never 变成 'github',第 11 行的条件类型
自动开始走 then 分支。没有任何一处代码被修改。
这是 TS 里最强大的机制之一,而它的全部力量就来自本章讲的 keyof +
条件类型这两块积木。第 07 章整章都在讲它怎么工作。 本章只负责让你知道
地基在这里。
本章第五条纪律
在 DSH 里读到"空接口 + 注释说会被合并"时,先别当成未完成的东西。
那是一个公开的扩展点:它今天返回never,明天会返回你的插件加的键。
评测一个空接口该不该补键,先看有没有人在用这个扩展点。
七、本章小结
keyof T把类型压成键的联合。keyof {}是never;联合取交集、
交叉取并集。keyof typeof X从一个as const常量反推键联合,是"让编译器替你维护
联合类型"的免费午餐。227 处keyof typeof大多是这个用法。T[K]有硬前提:K必须是合法键,否则 TS2537。这正是
WebhookEventOf必须写条件类型与回退分支的原因。- 四个内置工具类型是四块积木的拼法:
Exclude与Extract是同一行代码
的两个分支对调;NonNullable靠把null | undefined放在右边来关闭分发。 noUncheckedIndexedAccess决定索引访问是否要处理缺失。写索引签名时
必须确认它开着。- 空接口 + 注释说会被合并 = 公开扩展点,不是未完成的代码。
下一章进入条件类型与 infer——本章的 WebhookEventMap[K] 只差一层条件
就变成了类型编程的入口,而 infer 负责把"判断"升级成"提取"。
本章实验(附录)
实验脚本:[labs/M3-keyof-lab.tslabs/M3-keyof-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M3-keyof-lab.ts"
这个实验做什么:keyof 的结果无法可靠地"打印"出来——第 01、02 章
已经两次证明"让类型报错"这种手段会因写法不同而产生误导。因此本实验
改用赋值法判定:把结果赋给一个已知类型,通过与否就是结论。
四个工具类型则逐行手写一遍并与内置版本对拍。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:keyof 的三个实测
✓ 空对象的 keyof 是 never
✓ 联合的 keyof 取键的交集(此处为 never)
✓ 交叉的 keyof 同时含 a 与 b
✓ 交叉的 keyof 取键的并集
── 组 2:索引访问的硬前提
✓ Map[string] 报 TS2537
✓ K extends keyof T 的写法通过
✓ 约束 T 的等价写法同样通过
── 组 3:四个工具类型逐行手写
✓ MyExclude 与内置 Exclude 行为一致
✓ MyExtract 与内置 Extract 行为一致
✓ MyNonNullable 与内置 NonNullable 行为一致
✓ MyReturnType 与内置 ReturnType 行为一致
✓ 手写 MyExclude / MyExtract 真的在剔成员(不是碰巧通过)
── 组 4:真实锚点
✓ 空的 WebhookEventMap 使条件类型退化为 JsonValue
✓ 条件类型在 K 命中已知键时给出精确载荷
✓ 真实代码的 K 索引报 TS2536(类型参数不能索引)
✓ 而具体 string 索引报 TS2537(两个诊断码不可混用)
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。