第 07 章 声明合并与模块增强 ★
代码基线:commit
a89bca1316(2026-10-06)| DSH0.2.0-rc.1| 本系列讲次:T6本系列的最大差异化资产。全书 1049 处
declare module,市面几乎无人成篇写过。
本章导读
这一章讲 TS 里唯一能"往别人的类型上加东西"的机制。
前面六章的所有构造都作用在你写下的类型上。声明合并不一样:它让 A 文件
能够给 B 文件声明的接口添加成员。这在现实工程里极其必要——因为大多数类型
不该由一个人拥有。
DSH 的实测比例能说明一切:
| 形态 | 数量 |
|---|---|
declare module '包名'(模块增强) |
1034 |
declare global |
7 |
148 : 1。 也就是说 DSH 的扩展性几乎全部建立在"给别人的模块补成员"上,
而不是往全局命名空间里塞东西。本章要讲透这条主路,以及它的一个静默陷阱。
读完本章你要能回答:"我把接口名拼错了一个字母,编译器会告诉我吗?"
答案是不会——而且失败方式极其阴险。
一、锚点:一个 32 文件共同定义的接口
第 05 章已经埋好伏笔:SessionEventMap 散落在 32 个非测试文件里。
现在看其中一个最标准的例子,
[packages/compaction/compaction/src/types.ts``packages/compaction/compaction/src/types.ts:
import type { CompactionId } from './brand.ts'
export type { CompactionId }
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Marks the start of a compaction — log-only, holds the lock until
* `compaction/end`. A numbered owner is strictly enclosed by that open turn;
* `null` identifies a standalone manual transaction between turns.
*/
'compaction/start': { ... }
...
}
}
读法:
compaction包没有修改 session 包的任何文件- 它只是在自己包里又声明了一次同名接口,并且包在
declare module '@deepseek-ai/dsh-session/types'里面 - 编译器把这两处声明合并成了一个接口
- 于是
SessionEventMap同时拥有 session 包的 15 个键和 compaction 的 4 个键
这就是 1034 处 declare module 的共同形状。
二、接口合并:唯一的"加字段"手段
2.1 实测:合并是静默的
interface M { a: string }
interface M { b: number }
const t1: M = { a: 'x', b: 1 } // ✓ 通过
同一个接口声明两次,编译器一声不吭地把它们并成一个。 没有警告,
没有提示。它是语言规范的一部分,不是特性也不是 hack。
2.2 type 别名不行
type T = { a: string }
type T = { b: number }
error TS2300: Duplicate identifier 'T'.
error TS2300: Duplicate identifier 'T'.
可合并的只有三种声明:interface、namespace、enum,以及类(与同名的接口)。
type 别名不在其中。同理 const / let / function / class 也不能重复声明。
2.3 同名成员类型冲突会报错
interface C { x: string }
interface C { x: number }
error TS2717: Subsequent property declarations must have the same type.
Property 'x' must be of type 'string', but here has type 'number'.
注意这个错误只在类型不同时出现。同名同类型是合法的——
interface M { a: string } 和 interface M { a: string } 会合并成同一个 a。
本章第一条纪律
想要"往已有类型上加字段",只有
interface能做到。
type别名做不到,而且报错是TS2300 Duplicate identifier——
看到它就要想到"我是不是该用 interface"。
三、declare module:把合并做到模块边界
3.1 基本形状
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap { 'compaction/start': {...} }
}
declare module 'X' 的含义分两种情况,判据是 X 能不能被解析成一个真实模块:
| 情况 | 含义 |
|---|---|
| X 解析得到真实模块 | 增强(augmentation):与该模块的声明合并 |
| X 解析不到 | 环境声明(ambient):凭空声明一个模块 |
模块名不存在时会报 TS2664:
error TS2664: Invalid module name in augmentation, module './nope' cannot be found.
3.2 本章的核心陷阱:拼错接口名
这一条值得单独一节,因为它是静默的。
模块存在,但接口名拼错了:
import type { E } from './base.ts' // base.ts 里有 interface E { base: string }
declare module './base.ts' {
interface NotThere { ghost: boolean } // 拼错了!base.ts 里没有 NotThere
}
export const t1: import('./base.ts').NotThere = { ghost: true } // ✓ 通过
export const t2: E = { base: 'x' } // ✓ 通过
实测:全部通过,零错误零警告。
发生了什么:
NotThere在base.ts里不存在,所以它不是增强,而是一个新的声明- 但它被放进了一个确实是真实模块的
declare module块里,
于是被当成"向 base.ts 添加 NotThere" import('./base.ts').NotThere于是能解析——解析到一个你自己造出来的幽灵- 真正的
E完全没有被增强
失败方式极其阴险:你以为增强了别人,实际是凭空造了个平行宇宙。
下游代码 import type { NotThere } 同样能编译,
直到某天有人去 base.ts 里找这个接口,发现根本没有。
为什么 TS2664 没能保护你:它只检查模块名,不检查模块里有没有那个接口。
因为"新增一个接口"和"增强一个接口"在语法上无法区分——
declare module 的两种含义共用一套语法。
3.3 一条实测出来的格式约束
写增强时有个不显眼的限制:declare module '...' 的模块名不能用带 .ts
后缀的相对路径,除非编译器额外开了 allowImportingTsExtensions。实测:
error TS5097: An import path can only end with a '.ts' extension when
'allowImportingTsExtensions' is enabled.
而 DSH 的约定([AGENTS.md``AGENTS.md:"use package names across
packages and .ts in local relative imports")本来是本地相对导入一律带 .ts。
两条规则在 declare module 里正好冲突。
于是看真实代码就知道 DSH 怎么选了——第 1 节那个例子用的是
裸包名 '@deepseek-ai/dsh-session/types',不是相对路径:
declare module '@deepseek-ai/dsh-session/types' { ... }
这不只是风格,是被 TS5097 逼出来的。 而且裸包名在这里更对:你要增强的是
一个包的公开类型面,用包名正好表达这个意思。
本章第二条纪律
declare module里一律用裸包名。相对路径要么触发 TS5097,
要么在开了 flag 的仓库里与上游约定打架。
本章第三条纪律
写完
declare module增强,立刻验证一次:在被增强的那个文件里,
写一行const _check: MyInterface = {}——如果那个文件里本来没有这个接口,
它会在增强的那个包**里报错,而不是在原文件里。这正是 TS2664 保护不到的地方。更省事的做法:把拼写与原文件核对一遍。这个错误没有任何工具能替你发现。
四、declare global:7 处,以及它为什么不常用
declare global {
interface Window { myThing: string }
}
它把成员加到全局作用域。DSH 里有 7 处,而 declare module 有 1034 处。
这个比例说明了一条工程判断:除非真的需要跨所有模块可见,否则不要用 declare global。
原因很实际:
- 全局增强没有归属——没人能说清
Window.myThing归谁 - 它对每一个文件生效,包括那些与增强者毫无关系的文件
- 拼错的代价与第 3.2 节相同,但影响面是全仓库
本章第四条纪律
增强要走
declare module '具体的包',
因为那样至少归属明确——只有 import 了这个包的文件才能看见。
declare global是最后手段。
五、为什么 DSH 非要它不可
现在回答本章开头的问题:为什么一个正常的库需要 1034 处增强?
答案在
[AGENTS.md``AGENTS.md 第 133 行,这条规则把类型系统的角色说透了:
Typed events use declaration merging and merge-extensible maps.
…SessionEventMapmembers are required-on-read by default — builds that
do not know a type refuse the log unless the event carries the envelope's
ignorable: true; only structural format changes bumpSESSION_FORMAT_VERSION.
拆开看这句话的三层含义:
5.1 类型是持久化边界的承重结构
"members are required-on-read by default"——意思是:一个不认识的事件类型,
读取路径拒绝这份日志,而不是跳过它。
这是刻意的。注释里写了理由:
silently skipping a required event would reconstruct a wrong session
跳过一个必需事件会重建出一个错误的会话。 与其得到一个看似正常的错误会话,
不如明确拒绝。
于是类型系统在这里不是"写起来舒服"的工具,而是跨进程、跨版本正确性的保障。
一个没写增强的下游构建,编译期看不到某个事件,运行时就会拒绝读日志——
编译期与运行时的行为在这里是对齐的,而这正是第 01 章那条
Model-visible ⟺ logged 规则的延伸。
5.2 代价:消费端不能做穷尽检查
AGENTS.md 第 134 行紧接着给了对策:
Switch on discriminant tags. Closed unions end in
assertNever;
merge-extensible unions fall through a documented default.
因为 SessionEventMap 的键随时可能被别的包加进去,任何
switch (event.type) 都不可能穷尽。所以这种联合必须
带一个写明理由的 default 分支,而不是 assertNever。
这是可扩展性付的账:你换来"别人能给我加成员",代价是
"我不能再假设自己看全了"。
5.3 所以第 5.2 节那个陷阱格外危险
回到第 3.2 节。SessionEventMap 上有 32 个文件在做增强,
每个都可能拼错。而这个错误的失败模式是:
- 拼错 → 幽灵接口 → 下游拿不到真正的载荷类型 → 落到 5.2 节的 default 分支
- 静默降级,不报错
这不是理论风险。这就是为什么第 3.2 节那条纪律(本章第三条)值得单独强调:
在一个"拼错 = 静默降级"的系统里,唯一的防线是人工核对。
本章第五条纪律
在可合并扩展的接口上工作,先确认三件事:
- 我的增强真的落到那个接口上了吗(第 3.2 节的幽灵陷阱)
- 我消费它的每一处都带 default 分支吗(5.2 的代价)
- 我新增的成员有 JSDoc 吗(AGENTS.md 要求事件 JSDoc 带
@mode与 payload@param)
六、本章小结
- 接口合并是静默的;
type别名不能合并(TS2300);
同名成员类型不同会报 TS2717。 declare module 'X'在 X 可解析时是增强,否则是环境声明。
模块名不存在报 TS2664。- 核心陷阱:模块存在、接口名拼错 → 零报错,造出幽灵类型。
declare module的两种含义共用语法,编译器分不出来。 declare global只有 7 处,因为它没有归属、对全仓库生效。
增强优先走具体包名。declare module里一律用裸包名:相对.ts路径会触发 TS5097
(除非开allowImportingTsExtensions),DSH 的增强全部使用裸包名。- DSH 需要 1034 处增强,是因为
SessionEventMap跨包扩展且在持久化边界承重:
不认识的事件类型 → 拒绝读日志。代价是消费端不能穷尽switch。 - 可扩展性的账:换来"别人能加成员",付出"我不敢假设看全了"。
下一章回到单个包的内部,讲一个本章反复出现的东西:不透明 id 在跨边界之后
怎么活下来——第 02 章的 Branded 到这里要面对序列化的考验。
本章实验(附录)
实验脚本:[labs/M7-augment-lab.tslabs/M7-augment-lab.ts
运行(在仓库根目录):
export PATH="/opt/homebrew/bin:$PATH"
cd /Users/ygs/ygs/deepseek-harness
node --import tsx/esm "ts-源码精读/labs/M7-augment-lab.ts"
这个实验做什么:本章所有论断用真实编译判定,其中组 2 专门复现
那个静默陷阱——它在"正确写法"与"拼错写法"之间制造一个只有一行差别的
对照,用来证明编译器不会替你发现它。
预期输出(本机实测,任何一行对不上就说明基线变了):
── 组 1:接口合并的基本规则
✓ interface 重复声明静默合并
✓ type 别名重复声明报 TS2300
✓ 同名成员类型冲突报 TS2717
✓ 同名成员类型相同则合法合并
── 组 2:静默陷阱(本章核心)
✓ 增强真实存在的接口会真的合并
✓ 增强不存在的接口零报错,造出幽灵类型
✓ 真正的目标接口未被增强——合并静默失败
✓ declare module 里用相对 .ts 路径需要额外 flag(DSH 因此一律用裸包名)
✓ 模块名不存在时报 TS2664(只保护了一半)
── 组 3:DSH 的真实增强
✓ compaction 包确实用 declare module 增强 session/types
✓ SessionEventMap 的增强散落在多个文件(本仓库实测 32 个非测试文件)
✓ declare module 远多于 declare global(本组实测 69 : 0)
全部断言通过。
本章的勘误条目见 [附录 A · 勘误总表附录A-勘误总表.md。