TypeScript 精读 05:映射类型与模板字面量

第 05 章 映射类型与模板字面量

代码基线:commit a89bca1316(2026-10-06)| DSH 0.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] 的成员。"

它看起来什么都没做——实际上它做了两件事,都很关键:

  1. 保留可选性。{ a?: string; b: number } 经过 Copy 之后,a 仍然是可选的。
    这叫同态映射(homomorphic):形如 { [K in keyof T]: ... } 的映射
    会自动继承原类型的修饰符(readonly、?)。

    实测:const d1: Copy<{ a?: string; b: number }> = { b: 1 } 通过。

  2. 遍历。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"'.  

三件事同时发生:

  1. as const 把数组元素收窄成字面量类型(第 06 章)
  2. typeof ARR 取出这个值的类型
  3. [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;
run pnpm run gen-persistence-catalog to regenerate
(verified fresh by pnpm run verify-persistence-catalog, part of doc-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 persisted SessionEvent.ignorable marker 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"的方案,
失败的往往不是技术,是没先问"这个事实的形态类型系统看不看得见"。

本章第四条纪律

在真实工程里,"这个联合能不能自动生成"是个架构问题,不是语法问题。
先问两句:

  1. 这个值的形态是类型系统能推导的吗(as const 数组 / 对象)?
  2. 它的定义是集中的吗(一个接口),还是散落并合并的(多个包的声明合并)?

两个都是"是",就用类型推导;否则需要生成器与门禁——
而这恰恰是大型 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。