TypeScript 精读 07:声明合并与模块增强

第 07 章 声明合并与模块增强 ★

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

读法:

  1. compaction 包没有修改 session 包的任何文件
  2. 它只是在自己包里又声明了一次同名接口,并且包在
    declare module '@deepseek-ai/dsh-session/types' 里面
  3. 编译器把这两处声明合并成了一个接口
  4. 于是 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.
…SessionEventMap members 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 bump SESSION_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 节那条纪律(本章第三条)值得单独强调:
在一个"拼错 = 静默降级"的系统里,唯一的防线是人工核对。

本章第五条纪律

在可合并扩展的接口上工作,先确认三件事:

  1. 我的增强真的落到那个接口上了吗(第 3.2 节的幽灵陷阱)
  2. 我消费它的每一处都带 default 分支吗(5.2 的代价)
  3. 我新增的成员有 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。