本节目标:读完这一节,你能说清「声明合并」的几条规则,能区分**在脚本里
declare module(全新声明)与在模块里declare module(扩充)**这两种语义相反的写法,能安全地给 Express 的Request、process.env、Window甚至内置原型补成员,并且能看懂TS2664、TS2717这类报错背后的原因。
12.3 模块扩充与全局类型增强
前两节我们都在「新建」声明:为一个没有类型的库凭空写一份。这一节换一种操作——目标模块已经有类型了,我们只想往上面加东西。
这在真实工程里出现频率极高:中间件往 req 上挂了 user、构建工具往 process.env 里注入了变量、埋点 SDK 在 window 上挂了全局函数。这些运行时确实存在的东西,官方类型不可能预知,得由你在项目里补。
声明合并:一切的地基
TypeScript 允许同名的多个声明「叠」在一起,而不是报重复定义。这叫声明合并(declaration merging)。它不是什么冷门特性,恰恰是标准库自己的组织方式。
| 同名声明组合 | 合并结果 | 能否合并 |
|---|---|---|
interface + interface | 成员取并集 | 可以 |
namespace + namespace | 成员取并集 | 可以 |
namespace + class / function / enum | 命名空间成员挂到实体上 | 可以 |
class + class | 不允许 | 报错 |
type + type | 不允许 | 报错 |
interface + type | 不允许 | 报错 |
界面(interface)能合并,是因为它只描述「形状」;类型别名(type)不能,因为它是一个唯一的指代。
interface User {
id: number;
}
interface User {
name: string;
}
// 两次声明被合并
const u: User = { id: 1, name: "Ada" }; // 两个字段都必填
class 与 namespace 的合并我们在 12.2 为无类型库编写声明
里用过(EventBus.Options),它的作用是给一个运行时实体挂上「静态类型」。
理解合并是理解扩充的前提:模块扩充本质上就是「在目标模块的 interface 上再做一次 interface 声明」。
模块扩充:两个语义完全相反的写法
先看正确的写法。假设你要给 Express 的 Request 加一个 user 字段:
// src/types/express.d.ts
import "express";
declare module "express" {
interface Request {
user?: { id: number; name: string };
requestId: string;
}
}
关键在于第一行 import "express";——它看起来毫无作用,却是整个方案成立的条件:
- 有了顶层
import,这个文件成了模块; - 在模块里,
declare module "express"的语义是「扩充已有的 express 模块」; - 在脚本里(没有顶层
import/export),同样的写法语义是「声明一个叫 express 的新模块」,它会覆盖官方类型。
用错代价极大。看下面这段:
// ❌ 这是脚本文件(没有 import / export)
declare module "express" {
interface Request {
user?: { id: number };
}
}
此时 Express 官方的 Request 上原有的 params、query、body、get()、header() 全部消失,业务代码里 req.body 会直接报属性不存在。这类问题很难排查,因为「req.user 能用」这个假象会让人以为扩充成功了。
判断法则:扩充必须写在模块文件里,而让它成为模块最省事的办法就是在顶部写一行 export {}; 或 import "目标模块";。
// 两种让文件成为模块的写法,任选其一
export {};
// 或者
import "express";
declare global:在模块里扩展全局
既然模块文件里的声明都被关在模块作用域内,那要扩展全局怎么办?答案是 declare global:
// src/types/global.d.ts
export {}; // 先成为模块
declare global {
interface Window {
__TRACKER__?: (event: string, payload?: Record<string, unknown>) => void;
}
}
declare global 块内的 interface Window 会与内置的 Window 合并,于是:
window.__TRACKER__?.("click", { target: "buy" });
// 编译通过;没有这个声明时 TS2339: Property '__TRACKER__' does not exist on type 'Window'
declare global 里还可以声明全局变量:
export {};
declare global {
// var 是声明全局变量的正确方式
var __APP_VERSION__: string;
// 也可以放类型
type Nullable<T> = T | null;
}
这里必须用 var 而不是 let / const。 因为 let / const 在 declare global 里会与外层作用域产生冲突,且无法被声明合并补充。这是 TypeScript 里少见的「必须用 var」的场合。
场景一:给 Express 的 Request 加 user
这是扩充最经典的用法。认证中间件把用户挂到 req 上,路由处理函数直接用:
// src/middleware/auth.ts
import type { RequestHandler } from "express";
export const auth: RequestHandler = (req, res, next) => {
const token = req.header("authorization");
req.user = token ? { id: 1, name: "Ada" } : undefined;
req.requestId = req.header("x-request-id") ?? crypto.randomUUID();
next();
};
配套的扩充声明(src/types/express.d.ts,内容即上面那段)让 req.user 与 req.requestId 在所有路由里都有类型:
// src/routes/profile.ts
import { Router } from "express";
export const profile = Router();
profile.get("/me", (req, res) => {
if (!req.user) {
res.status(401).json({ error: "unauthorized" });
return;
}
res.json({ id: req.user.id, name: req.user.name });
});
注意 user 声明成了可选(user?:)。因为 Request 类型并不保证中间件一定跑过,声明成必填就是撒谎——某个没挂 auth 的路由访问 req.user.id 会在运行时炸掉,而编译器不会提醒。
同理,如果某字段只由特定中间件提供,更严谨的做法是窄化类型:定义一个 AuthedRequest extends Request { user: User },在需要的地方 as 一次。这是「类型诚实」与「使用便利」之间的经典取舍。
场景二:给 process.env 加字段
process.env 的类型来自 @types/node,索引签名是 string | undefined。这意味着下面这行不会报错,但 PORT 可能真的是 undefined:
const port = Number(process.env.PORT); // number,但 PORT 缺失时是 NaN
扩充它,把配置项变成必填且带字面量约束:
// src/types/env.d.ts
export {};
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
PORT?: string;
FEATURE_NEW_UI?: "true" | "false";
}
}
}
扩充之后有两个直接收益:
process.env.NODE_ENV;
// "development" | "production" | "test" —— 可以配合 switch 做穷尽检查
switch (process.env.NODE_ENV) {
case "development":
break;
case "production":
break;
case "test":
break;
// 少写一个分支时 TS 会报错,这就是穷尽检查的价值
}
ProcessEnv 是 NodeJS 命名空间下的 interface,所以扩充要写成 namespace NodeJS { interface ProcessEnv {...} } 的嵌套形式。扩充嵌套类型时必须逐层还原命名空间路径,这是初学者最常见的失败原因。
另外要清醒地认识到:这只是声明,它不会真的把变量注入进去。运行时的环境变量仍然由 .env 文件、容器配置或部署平台提供,声明只负责让编译器知道有哪些。要做运行时校验(比如「DATABASE_URL 缺失就立刻退出」),得靠 schema 校验,见 13.2 Zod 模式验证与类型推导
。
场景三:扩展内置原型
给内置对象加方法在类型层面也能做,但请把它当作最后手段:
// src/types/prototype.d.ts
export {};
declare global {
interface Array<T> {
sum(this: number[]): number;
}
}
Array.prototype.sum = function (this: number[]) {
return this.reduce((acc, n) => acc + n, 0);
};
这里有两处细节:
- 用
this: number[]约束接收者,避免["a"].sum()通过编译; - 实现必须单独写。
declare global只提供类型,运行时方法得自己挂上去。类型擦除意味着类型有了不等于实现有了——这正是第 13 章要讲的运行时盲区。
风险在于全局污染:库 A 和库 B 都给 Array.prototype 加了 sum,类型上会合并成同一个签名,运行时后者覆盖前者,而编译器不会报任何错。所以除非是全局 polyfill(如 Array.prototype.at),否则更推荐用工具函数。
声明合并的坑
坑一:成员类型冲突。
TS2717: Subsequent property declarations must have the same type.
Property 'user' must be of type 'User | undefined',
but here has type 'string'.
扩充只能新增成员,或在原有成员类型完全一致的前提下重复声明。想改窄、改宽、改名都不行——那需要类型断言或包装层。
坑二:目标模块无法解析。
TS2664: Invalid module name in augmentation. Module 'express' resolves to an
untyped module at '/app/node_modules/express/index.js', which cannot be augmented.
扩充的前提是 TypeScript 能解析到那个模块的类型。没装 @types/express 时,express 是个无类型模块,无从扩充——此时只能退回 12.2 的全新声明写法。
坑三:忘了让文件成为模块。 后果就是前面说的「覆盖而非扩充」,症状是官方成员集体消失。
坑四:扩充声明没被编译到。 与 12.2 同理,src/types/** 必须在 tsconfig.json 的 include 里。
坑五:在一个文件里既想当全局脚本又想当模块。 这是自相矛盾的。一个文件要么是脚本(声明全局)、要么是模块(用 declare global 显式扩展全局),不能两者兼得。
坑六:把扩充当运行时。 声明文件在编译后被完全丢弃。中间件没写、环境变量没配、原型方法没挂,类型再漂亮也只是纸面承诺。
什么时候该扩充,什么时候不该
| 情形 | 推荐做法 |
|---|---|
框架约定俗成的扩展点(req.user) | 扩充,社区普遍接受 |
| 环境变量、构建期常量 | 扩充,收益明显 |
| 只在一个模块里用到的额外字段 | 定义子类型,不要动全局 |
| 想改掉第三方库已有成员的语义 | 包装或适配层,不要扩充 |
| 给内置原型加业务方法 | 用工具函数,尽量避免 |
判断标准很简单:这个新增的东西是不是「全局成立」的? 如果只有某个路由、某个模块才成立,那它就不该进全局类型。
延伸阅读
- 装饰器与元编程(另一类类型扩展手段):/typescript-decorators-metaprogramming/
- Express 与 Node 后端工程实践:/nodejs-express-guide/
- 模块解析与 ESM/CJS 互操作:/typescript-module-resolution-esm-cjs/
- 类型层面的进阶玩法:/typescript-type-level-programming/
小结
- 声明合并是扩充的地基:
interface与namespace可以同名叠加,type与class不行。 - 在脚本里
declare module "x"是全新声明(会覆盖官方类型),在模块里才是扩充;让文件成为模块只需一行export {};。 - 在模块文件里扩展全局必须用
declare global,全局变量声明要用var。 - 典型场景三连:给
Request加user(字段要声明为可选才诚实)、给ProcessEnv加配置项(能获得字面量联合与穷尽检查)、给Array加方法(风险最高,能不用就不用)。 TS2717(成员类型冲突)与TS2664(目标模块无法解析)是扩充最常见的两个报错。- 扩充只改类型不改运行时:实现要自己写,数据要自己校验。
至此第 12 章结束。我们从「类型从哪来」(.d.ts 与 @types)讲到「没有类型怎么办」(手写声明),再到「已有类型怎么加」(模块扩充)——这一章处理的都是类型的供给问题。
但有一条线始终贯穿其中:声明文件在编译后被完全丢弃,类型永远不会替你检查运行时。 下一章我们就正面处理这个主题:类型擦除留下了哪些盲区,以及如何用 Zod 这类 schema 方案把类型安全延伸到运行时。
阅读导航:上一节:12.2 为无类型库编写声明 · 下一节:13.1 类型擦除带来的运行时盲区 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。