第 05 章 映射类型与模板字面量
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T4
本章导读
这一章讲最后两块积木:把一个键集合变成新类型,以及把字符串的"格式"写进类型。
前四章的积木是查询单个键(第 03 章)和判断单个条件(第 04 章)。
这一章把它们放进循环:遍历所有键(映射类型),并第一次让类型承载
字符串的形状(模板字面量)。
DSH 全仓 1825 处模板字面量类型、34 处映射类型。数量悬殊不是偶然——
模板字面量可以直接写、几乎不需要辅助类型,而映射类型往往藏在一个精心设计的
工具类型里。
本章的锚点不是一段漂亮代码,而是一个工程问题:
[packages/core/session/src/known-event-types.ts``packages/core/session/src/known-event-types.ts
里的 50 个事件名。我们会发现 DSH 没有用类型去推导它——而这恰恰是
本章最值得学的一课。
读完本章你要能回答:"为什么 typeof KNOWN_SESSION_EVENT_TYPES 在整个仓库里
一次都没出现?"
一、映射类型:把一个键集合变成新类型
1.1 基本形状
type Copy<T> = { [K in keyof T]: T[K] }
读作:"对 T 的每一个键 K,产出一个键叫 K、值是 T[K] 的成员。"
它看起来什么都没做——实际上它做了两件事,都很关键:
-
保留可选性。
{ a?: string; b: number }经过Copy之后,a仍然是可选的。
这叫同态映射(homomorphic):形如{ [K in keyof T]: ... }的映射
会自动继承原类型的修饰符(readonly、?)。实测:
const d1: Copy<{ a?: string; b: number }> = { b: 1 }通过。 -
遍历。
BakedActions(第 04 章那行)靠的就是它。
1.2 异态映射:不遍历,而是给键起新名字
[K in keyof T as NewK] 里的 as 子句重命名键。实测:
type Rename<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: T[K] }
type R = Rename<{ name: string; age: number }>
error TS2353: Object literal may only specify known properties,
and 'name' does not exist in type 'Rename<{ name: string; age: number; }>'.
原键名消失了,必须写 { getName: 'x', getAge: 1 }。
string & K 那个交叉是为了让 Capitalize 接受 K(它要求 string),
是标准写法。
1.3 as 还可以过滤
type OnlyStrings<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] }
as 子句返回 never 就等于把这个键删掉。这与第 04 章的
Exclude 是同一个技巧——never 从联合里消失,于是等于"剔除"。
本章第一条纪律
[K in keyof T as X]里的X返回never= 删除该键。
这是"过滤"的实现方式,比先Exclude键集合再映射更直接。
二、模板字面量:让类型承载字符串的形状
type ToolEvent = `tool/${string}`
它表示"任何以 tool/ 开头的字符串"。这不是模糊匹配,而是类型层面的格式契约。
2.1 从事件名反推动作
api-catalog 与 known-event-types 里那些 '域/动作' 形式的名字,
天然可以拆。实测:
type ActionOf<E> = E extends `${string}/${infer A}` ? A : never
const a1: ActionOf<'tool/result'> = 'result' // ✓ 通过
${string}/${infer A} 说的是"斜杠前是任意字符串,斜杠后我暂时叫 A"。
这是第 04 章的 infer 在模板字面量上的用法——它必须用模板字面量才能表达
"按分隔符切分"这种结构,普通 infer 做不到。
2.2 用模板字面量做过滤
Exclude 配上模板字面量,可以按前缀剔除一整类事件。实测:
const TOOL_EVENTS = ['tool/call', 'tool/result', 'tool/ptc-dispatch'] as const
type All = typeof TOOL_EVENTS[number]
type OnlyTool = Exclude<All, `${'turn'}/${string}`>
const t1: OnlyTool = 'tool/result' // ✓ 通过
const t2: OnlyTool = 'turn/start' // ✗ TS2322
注意 `${'turn'}/${string}` 这个写法——它等价于 'turn/' 加上任意后缀。
写成模板字面量而不是字符串字面量 'turn/start',才能匹配所有 turn/ 开头的事件。
本章第二条纪律
模板字面量 +
Exclude是"按前缀筛掉一整类"的正确写法。
手写'turn/start'只能剔掉一个事件,漏一个就静默失效。
2.3 四个内建的字符串变换
type A = Uppercase<'abc'> // 'ABC'
type B = Lowercase<'ABC'> // 'abc'
type C = Capitalize<'abc'> // 'Abc'
type D = Uncapitalize<'ABC'> // 'aBC'
它们全部在类型层面完成,产物为零(第 01 章)。Rename 里用到的
Capitalize 就是其中之一。
三、typeof ARR[number]:把值变成联合
本章最重要的单个技巧。实测:
const TOOL_EVENTS = ['tool/call', 'tool/result', 'tool/ptc-dispatch'] as const
type ToolEvent = typeof TOOL_EVENTS[number]
const e1: ToolEvent = 'tool/call' // ✓
const e2: ToolEvent = 'turn/start' // ✗ TS2322
error TS2322: Type '"turn/start"' is not assignable to type
'"tool/call" | "tool/result" | "tool/ptc-dispatch"'.
三件事同时发生:
as const把数组元素收窄成字面量类型(第 06 章)typeof ARR取出这个值的类型[number]从数组类型里取出元素类型——也就是那个联合
这是第 03 章说的"让编译器替你维护一个联合类型"的具体实现。
DSH 那 1825 处模板字面量之所以存在,是因为它们大量建立在
typeof SOME_ARRAY[number] 之上:先有一份 as const 的真实词表,
再由它生成联合类型,最后用模板字面量去描述"这个联合里哪些名字以什么前缀开头"。
本章第三条纪律
手写联合类型就是负债。 每当你要写
type X = 'a' | 'b' | 'c'时,先问:这份列表是不是已经以值的形式存在于
某处?如果是,改成typeof LIST[number]——只要列表更新,类型自动跟着更新。
四、锚点:51 个事件名,与一个诚实的放弃
现在回到
[known-event-types.ts``packages/core/session/src/known-event-types.ts。
它的第 22 行导出:
export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'approval/asked', 'approval/decided', 'approval/policy',
'assistant/attempt', 'assistant/message',
...
'turn/end', 'turn/start', 'user/message',
'workspace/changes',
])
而 [packages/core/session/src/types.ts:288``packages/core/session/src/types.ts:288
起的 SessionEventMap 用接口键声明了同一批名字:
'turn/start': { turn: number }
'turn/end': { turn: number; reason: TurnEndReason }
'step/start': { turn: number; step: number }
...
按第三节的纪律,这里应该写:
export type SessionEventType = typeof KNOWN_SESSION_EVENT_TYPES[number]
但整个仓库里 typeof KNOWN_SESSION_EVENT_TYPES 一次都没出现。 实测确认。
4.1 两个原因,第一个比预想的深
原因一:Set 形态取不出元素类型。 实测:
declare const S: ReadonlySet<string>
type FromSet = typeof S[number] // ← 硬错误
error TS2537: Type 'ReadonlySet<string>' has no matching index signature for type 'number'.
不是"退化成 string",而是连索引都取不到——ReadonlySet 的索引签名是
[Symbol.iterator],没有 [number]。就算有,new Set(['a','b']) 的元素类型
也退化成了 string。
原因二(更关键):类型声明本身是散落并合并的。
SessionEventMap 是个可合并扩展的接口。本仓库实测:它散落在
32 个非测试文件里声明或增强,分布大致是:
| 文件 | 声明的事件键数 |
|---|---|
packages/core/session/src/types.ts |
15 |
packages/api/session-controller/src/types.ts |
17 |
packages/compaction/compaction/src/types.ts |
4 |
packages/llm/llm-retry/src/types.ts |
2 |
packages/interaction/user-approval/src/types.ts |
2 |
base 那一个文件只占 15 个,而运行时词表有 50 个。差额全部来自其他包的
声明合并。 所以"手写一份联合类型"在这里根本不是一个选项——没有一个人
能在一处写全它,因为它是由 32 个文件共同定义的。
(顺便说:这个"可合并扩展接口"本身就是第 07 章的主题。本章只需要知道它存在。)
4.2 它的替代方案:生成器 + 门禁
文件第 2–5 行的头注释把整条链路写清楚了:
GENERATED by
scripts/gen-persistence-catalog.ts— do not edit by hand;
runpnpm run gen-persistence-catalogto regenerate
(verified fresh bypnpm run verify-persistence-catalog, part ofdoc-sync).
这是一个三段式方案:
| 阶段 | 机制 | 保证的东西 |
|---|---|---|
| 单一事实源 | 32 个文件里 SessionEventMap 的声明合并 |
名字与载荷类型的绑定 |
| 生成 | gen-persistence-catalog.ts(用 globSync 遍历全树收集) |
运行时词表与类型不脱节 |
| 验证 | verify-persistence-catalog(在 doc-sync 门禁里) |
生成物不过期 |
第 7–20 行的模块注释还解释了为什么不用运行时注册表:
Downstream (out-of-repo) plugin events are outside this list by construction.
The persistedSessionEvent.ignorablemarker is the compatibility mechanism;
event-name registration was rejected because it does not classify
omission safety and would make reads [nondeterministic]
也就是说:曾经考虑过"让插件运行时注册事件名",被否决了——因为注册表无法
分类"漏读这个事件是否安全",而那正是 SessionEvent.ignorable 要解决的问题。
4.3 本章最重要的一课
类型系统能自动推导的时候用它,推导不了的时候用门禁补上。
这里同时有两个障碍:形态(
Set没有可索引的元素位置)与
分布(声明合并散落在 32 个文件里,没有一处可以手写)。
面对这类障碍,正确做法不是硬凑一个类型,而是
让一个生成器把编译期事实翻译成运行时事实,并让门禁保证翻译不过期。顺带一提:
SessionEventMap[keyof SessionEventMap]确实能得到联合类型
——第 03 章的keyof+ 索引访问在这里是成立的。但那条路给的是编译期的
别名,读取路径要的是运行时的Set。 类型管得了前者,管不了后者,
而后者才是这里真正需要的东西。
这是本系列到目前为止最重要的一次"类型系统边界"教学:类型不是万能的,
遇到它管不到的地方,补机制而不是硬凑。 那些"用类型系统做 X"的方案,
失败的往往不是技术,是没先问"这个事实的形态类型系统看不看得见"。
本章第四条纪律
在真实工程里,"这个联合能不能自动生成"是个架构问题,不是语法问题。
先问两句:
- 这个值的形态是类型系统能推导的吗(
as const数组 / 对象)?- 它的定义是集中的吗(一个接口),还是散落并合并的(多个包的声明合并)?
两个都是"是",就用类型推导;否则需要生成器与门禁——
而这恰恰是大型 TS 代码库的真实成本所在。
五、本章小结
- 映射类型遍历键集合。
[K in keyof T]是同态的,自动保留
readonly与可选性。 as子句重命名或删除键:返回never即删除。这是"过滤"的实现方式。- 模板字面量类型让类型承载字符串格式;配合
infer可以按分隔符切分,
配合Exclude可以按前缀筛掉一整类。 typeof ARR[number]把as const数组变成字面量联合。
手写联合类型就是负债。- 它只对"类型能看见的形态"有效。
Set<string>拿不到字面量联合——
DSH 对 51 个事件名的解法是生成器 + 门禁,而不是硬凑类型。
这条边界比任何语法都重要。
下一章讲 as const 与 satisfies。它们在这一章已经反复出现
(as const、satisfies Partial<Record<...>>),而且它们是整个第 03、05 章
那些技巧能否成立的前提。
本章实验(附录)
实验脚本:[labs/M5-template-lab.tslabs/M5-template-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M5-template-lab.ts"
这个实验做什么:前五节全部改用赋值法判定。组 4 是本章的核心——
它直接读 DSH 真实的 SessionEventMap 与 known-event-types.ts,
用脚本比对两边的名字集合,证明它们真的同步,也证明 typeof KNOWN_SESSION_EVENT_TYPES
真的推不出字面量联合。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:映射类型
✓ 同态映射保留可选性
✓ as 子句重命名键后原键消失
✓ as 子句返回 never 时删除该键
── 组 2:模板字面量
✓ 模板字面量反推出动作名
✓ Exclude 配模板字面量按前缀筛掉整类
✓ 前缀写法比手写字面量更宽(漏一个就会静默失效)
── 组 3:typeof ARR[number]
✓ as const 数组反推出字面量联合
✓ 联合外的名字被拒
── 组 4:DSH 的真实事件词表
✓ 词表里的每个事件名都能在某个 SessionEventMap 声明里找到(词表 ⊆ 声明集合)
✓ 收集集合是超集(67 ≥ 50,差额为同文件其它接口的键)
✓ SessionEventMap 的声明散落在多个包(本仓库实测 32 个非测试文件)
✓ typeof KNOWN_SESSION_EVENT_TYPES[number] 直接报 TS2537(Set 无数字索引签名)
✓ 它退化得到的是 Set<string> 本身,取不出任何元素联合
✓ 生成脚本与 doc-sync 门禁都在(类型的替代机制)
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。