《TypeScript编程入门》12.3 模块扩充与全局类型增强

本节讲 TypeScript 最易被误用的一项能力:不新建声明,而是往已有类型上「长」成员。先讲清声明合并的规则,再区分在脚本里 declare module 与在模块里 declare module 这两个语义相反的写法,演示给 Request 加 user、给 process.env 加字段等真实场景,并列出冲突报错与规避手法。读完你能安全地做全局增强。

本节目标:读完这一节,你能说清「声明合并」的几条规则,能区分**在脚本里 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)扩充,社区普遍接受
环境变量、构建期常量扩充,收益明显
只在一个模块里用到的额外字段定义子类型,不要动全局
想改掉第三方库已有成员的语义包装或适配层,不要扩充
给内置原型加业务方法用工具函数,尽量避免

判断标准很简单:这个新增的东西是不是「全局成立」的? 如果只有某个路由、某个模块才成立,那它就不该进全局类型。

延伸阅读

小结

  • 声明合并是扩充的地基: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 类型擦除带来的运行时盲区 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes