首页 / TypeScript 入门教程 / 编写声明文件:插件与扩充

TypeScript 入门教程

编写声明文件:插件与扩充

本教程共 80 篇 · 第 64 篇 · 更新于 2026-08-10 · 约 13 分钟阅读

TypeScriptTypeScript 入门教程声明文件.d.ts模块扩充module augmentationdeclare global

本节目标:学会在不动原始声明文件的前提下,给第三方库”追加类型”。你会学到 module augmentation 的标准写法、全局扩充(declare global)、以及为插件库声明类型的通用模式。

场景:你要给第三方库加功能

假设项目里用了 moment 库,它的类型声明已经通过 @types/moment 装好了。然后你引入了 moment-range 插件——它给 moment 对象增加了一个 range 方法:

import moment from "moment";
import { extendMoment } from "moment-range";

const extendedMoment = extendMoment(moment);
const range = extendedMoment.range("2024-01-01", "2024-01-31");

问题来了:@types/moment 里没有 range 方法的类型定义。你在用 range 的时候 TypeScript 会报错,说 moment 类型上不存在 range

你不能直接改 @types/moment 的源码——它在 node_modules 里,npm install 就会覆盖。也不能要求插件作者完美地发布类型——很多插件根本没有 .d.ts

解决办法就是模块扩充(Module Augmentation)。

模块扩充:修改已有模块的类型

模块扩充的写法是 declare module 配合原始模块名,在声明块内部添加新的导出:

// moment-range.d.ts —— 插件声明文件
import { Moment } from "moment";

declare module "moment" {
  interface Moment {
    range(start: Moment, end: Moment): Range;
  }

  interface Range {
    start: Moment;
    end: Moment;
    contains(date: Moment): boolean;
    // ...
  }
}

这段代码的意思是:“在 moment 这个模块里,Moment 接口还有一个 range 方法。“TypeScript 会把新增的内容合并进原始的 Moment 接口,而不是覆盖。

这里面有两个关键机制在起作用:

  1. declare module "moment":告诉 TS”我要向这个模块追加内容”,而不是重新定义它。
  2. interface 的声明合并:TS 允许同名的 interface 多次声明,会自动合并属性。这就是为什么 interface Moment { range(...) } 不需要 export 也能被 TS 识别——因为它是合并到已有的 Moment 接口上。

插件声明文件的完整模式

一个典型的插件声明文件结构:

// some-plugin.d.ts —— 插件声明模板
import * as m from "some-module";

// 方式一:给原模块添加新导出
declare module "some-module" {
  export function newMethod(x: string): number;

  // 扩充已有的 interface
  export interface SomeOptions {
    newOption?: boolean;
  }

  // 新增类型
  export interface PluginOptions {
    enabled: boolean;
  }
}

// 方式二:扩充全局类型(如果插件影响全局作用域)
declare global {
  interface String {
    fancyFormat(): string;
  }
}

注意:为了让 import 生效(TypeScript 将其视为模块文件而非脚本文件),插件声明文件必须有 importexport

扩充已存在的类型(interface 合并)

Interface 合并是 TypeScript 最优雅的设计之一。同一个名字的 interface 可以在多个地方声明,编译器会把它们合并。

比如扩充内置的 String 类型:

// string-extensions.d.ts
interface String {
  toTitleCase(): string;
}

然后你就可以在任何 .ts 文件中使用:

"hello world".toTitleCase(); // 类型检查通过

这同样适用于第三方库的 interface:

// 扩充 express 的 Request 类型,加一个 user 属性
import { User } from "./models";

declare module "express" {
  interface Request {
    user?: User;
  }
}

这在 Express + Passport.js 的认证场景中非常常见——中间件把 req.user 挂上去了,但 TS 不知道。通过 interface 扩充,整个项目的 req.user 都变成类型安全的。

Tip

interface 合并只对 interface 有效。如果你要扩充的是 type 别名,那没办法——type 不允许声明合并。这就是为什么写声明文件时,优先用 interface 而不是 type,给下游留一个扩充的口子。

全局扩充:declare global

有些插件不通过 import 使用,而是直接修改全局作用域。比如一个 polyfill 库往 Array.prototype 上加了个方法:

// array-polyfill.d.ts
declare global {
  interface Array<T> {
    toReversed(): T[];
    toSorted(compareFn?: (a: T, b: T) => number): T[];
  }
}

// 必须有这个 export,否则文件会被当成全局脚本
export {};

关键点:

  • declare global { ... } 包裹的声明会进入全局作用域。
  • 文件末尾的 export {} 是必需的——它告诉 TS 这是个模块文件(有 export),但同时不导出任何实际内容。如果没有这行,整个文件会被当成全局脚本,declare global 反而不起作用。

全局修改模块

还有一种特殊模式:模块被 import 后,自动修改全局作用域。比如 jest 的声明文件就是典型的全局修改模块——你 import 了它,全局就有了 describeitexpect 这些函数。

// global-modifying-module.d.ts
declare global {
  function describe(name: string, fn: () => void): void;
  function it(name: string, fn: () => void): void;
  function expect(value: any): Expectation;
  // ...
}

export {};

使用方只需要 import 一次,之后所有文件都能直接调用 describe/it/expect 而不需要显式导入。

扩充命名空间

如果原始声明用 namespace 组织了类型,你可能需要扩充 namespace 内部的类型:

// 原始声明
declare namespace MyLib {
  interface Options {
    debug?: boolean;
  }
}

// 扩充——再声明一次同名 namespace
declare namespace MyLib {
  interface Options {
    logLevel?: "verbose" | "silent";
  }
}

现在 MyLib.Options 同时拥有 debuglogLevel 和原来的其他属性。

注意:跟 module augmentation 不同,扩充 namespace 不需要 declare module,直接用 declare namespace 同名即可。因为 namespace 天然支持声明合并。

实战:为无类型的插件写声明

假设你有一个叫 chalk-templates 的包(给 chalk 加了模板字符串功能),没有类型声明。

它的使用方式:

const chalk = require("chalk");
require("chalk-templates")(chalk);

chalk`{red 红色的文字} {blue 蓝色的文字}`;

分析 API:

  • 默认导出是一个函数,接收 chalk 实例,给它加上模板字符串能力
  • 扩充了 chalk 使其可以作为标签模板使用

对应的声明文件:

// chalk-templates.d.ts
import chalk from "chalk";

// 声明模块的默认导出
declare module "chalk-templates" {
  function template(chalkInstance: typeof chalk): void;
  export = template;
}

// 扩充 chalk 类型,使其支持模板字符串调用
declare module "chalk" {
  interface Chalk {
    (strings: TemplateStringsArray, ...values: unknown[]): string;
  }
}

这样 import 了 chalk-templates 之后,chalk 就能以标签模板形式被调用,且类型检查通过。

扩充 vs 重新声明

很多新手分不清”扩充声明”和”重新声明”:

// ✅ 扩充:向已有模块添加内容
declare module "some-module" {
  export function newFeature(): void;
}

// ❌ 重新声明(通常不是你想要的):覆盖整个模块
declare module "some-module" {
  // 这里如果不写全所有的 export,原有的就丢了
  export function newFeature(): void;
}

两条 declare module "some-module" 在语法上都是合法的,但行为不同:

  • 如果第一条声明在原始声明文件里,第二条声明文件里的 declare module "some-module"合并而非覆盖。
  • 但如果原始声明文件不存在,第一条 declare module "some-module" 就定义了整个模块。后续的扩充声明才会合并。

总结

模块扩充的知识结构:

  • declare module "xxx" 向已有模块追加导出,用于为插件补充类型。
  • interface 合并是扩充的核心机制——同名 interface 多声明自动合并。
  • declare global 扩充全局作用域,需要配合 export {} 触发模块模式。
  • 扩充 namespace 直接用同名 declare namespace
  • 优先用 interface(可合并)而非 type(不可合并),给下游留余地。

下一章我们跳出单文件声明,来看 TypeScript 生态里最大的类型共享社区——DefinitelyTyped 和 @types。