编写声明文件:插件与扩充
本教程共 80 篇 · 第 64 篇 · 更新于 2026-08-10 · 约 13 分钟阅读
本节目标:学会在不动原始声明文件的前提下,给第三方库”追加类型”。你会学到 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 接口,而不是覆盖。
这里面有两个关键机制在起作用:
declare module "moment":告诉 TS”我要向这个模块追加内容”,而不是重新定义它。- 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 将其视为模块文件而非脚本文件),插件声明文件必须有 import 或 export。
扩充已存在的类型(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 都变成类型安全的。
Tipinterface 合并只对 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 了它,全局就有了 describe、it、expect 这些函数。
// 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 同时拥有 debug、logLevel 和原来的其他属性。
注意:跟 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。