首页 / TypeScript 入门教程 / 声明合并

TypeScript 入门教程

声明合并

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

TypeScriptTypeScript 入门教程声明合并declaration merging

本节目标:了解声明合并的存在和基本规则,理解它的正当使用场景,同时知道为什么不建议在日常业务代码里依赖它。

前面几章反复提到 interface 有一个 type 没有的能力——同名声明自动合并。这就是 TypeScript 的声明合并(Declaration Merging)机制。它很强大,但强大到容易被误用。这一章我们既要讲清楚它怎么工作,也要讲清楚什么时候不该用它。

什么是声明合并

简单说:当 TypeScript 编译器遇到多个同名的声明时,它不会报错,而是把它们合并成一个定义。

interface Box {
  height: number;
  width: number;
}

interface Box {
  scale: number;
}

// 合并后相当于:
// interface Box {
//   height: number;
//   width: number;
//   scale: number;
// }

const box: Box = { height: 10, width: 20, scale: 1.5 };

你不需要 extends,不需要 &——只要声明两次同名的 interface,TypeScript 就自动把它们拼到一起。这在其他类型系统里很少见,是 TypeScript 为了兼容 JavaScript 的灵活性而设计的独特能力。

interface 合并

interface 的合并规则很直观——成员累加,冲突报错

非函数成员的合并

同名非函数属性的类型必须完全兼容:

interface Person {
  name: string;
}

interface Person {
  name: string;   // ✅ 类型相同,没问题
  age: number;    // ✅ 新增属性
}

// 但如果类型不同呢?
interface Animal {
  species: string;
}

// interface Animal {
//   species: number;  // ❌ 报错:后续属性声明必须属于同一类型
// }

函数成员的合并

同名函数会被当作重载处理:

interface Calculator {
  compute(x: number): number;
}

interface Calculator {
  compute(x: string): string;
}

// 合并后相当于有两个重载:
// interface Calculator {
//   compute(x: number): number;
//   compute(x: string): string;
// }

// 实现时需要处理所有重载
const calc: Calculator = {
  compute(x: number | string): number | string {
    if (typeof x === "number") return x * 2;
    return x.repeat(3);
  },
};

calc.compute(5);       // 返回 number
calc.compute("hi");    // 返回 string

函数的重载顺序有讲究——后面声明的接口里的函数签名,优先级更高(会排在重载列表的前面)。这意味着如果你有两个 interface 各写了一个 compute 签名,编译器会先尝试匹配后面写的那个。

Note

实际上大部分开发者不会刻意记这个顺序,因为依赖声明合并来组织函数重载本身就很少见。这里了解就好,遇到报错回来看一眼就行。

namespace 合并

同名 namespace 也会合并——包括它们内部导出的成员:

namespace Utils {
  export function log(msg: string) {
    console.log(`[LOG] ${msg}`);
  }
}

namespace Utils {
  export function warn(msg: string) {
    console.warn(`[WARN] ${msg}`);
  }
}

// 合并后:Utils.log 和 Utils.warn 都可以用
Utils.log("启动");
Utils.warn("内存不足");
Note

namespace 在 TypeScript 1.x 时代是组织代码的主要方式,但从 ES2015 引入了模块(import / export)之后,namespace 的使用频率已经很低了。现代 TypeScript 项目很少会用到 namespace,更不用说 namespace 合并了。这里列出来主要是保持知识完整性。

枚举合并

同名枚举也会合并:

enum Color {
  Red = 1,
  Green,
}

enum Color {
  Blue = 3,
  Yellow,
}

// 合并后:
// enum Color {
//   Red = 1,
//   Green = 2,
//   Blue = 3,
//   Yellow = 4,
// }

console.log(Color.Red);    // 1
console.log(Color.Yellow); // 4

不过需要注意:后面声明的枚举必须给第一个成员赋初始值,否则编译器不知道从哪个数字开始递增,会直接报错。而且枚举合并在实际项目里极为少见——分两次定义同一个枚举通常是设计失误的信号。

class 与 interface 的合并限制

class 可以和同名 interface 合并,但这种合并不是”合在一起”,而是给类追加实例的类型声明

interface Album {
  artist: string;
}

class Album {
  title: string = "";
}

// Album 类现在有了 artist 的类型要求
// 实际上这相当于:class Album { title: string; artist: string }
// 但 artist 不会自动初始化——你需要在实例化后手动赋值

const a = new Album();
a.title = "Dark Side of the Moon";
a.artist = "Pink Floyd";

这里有个容易踩的坑:interface 给 class 加的只是类型声明,不是属性的实际实现。 artist 不会自动出现在 Album 的实例上——TypeScript 假设你会通过其他方式(比如构造函数、装饰器、或者外部赋值)来提供这个属性。

另外,class 不能和另一个 class 合并——同名 class 会直接报重复声明错误。

声明合并的正当用途

说了这么多”能做什么”,现在来说”该做什么”。

正当场景一:扩展第三方库的类型

这是声明合并最重要、最正当的用途。当你使用一个第三方库,想给它的类型打补丁时:

// express 的类型声明里可能没有这个方法
interface Request {
  currentUser?: { id: number; name: string };
}

// 现在所有 Request 对象都可以访问 currentUser 了
function handleRequest(req: Request) {
  if (req.currentUser) {
    console.log(req.currentUser.name);
  }
}

这种用法叫 Module Augmentation(模块增强),是声明合并机制的设计初衷之一。你不用 fork 库的 .d.ts 文件,也不用等官方更新,就能给现有类型添加自定义字段。

正当场景二:TypeScript 标准库自身的扩展

打开 TypeScript 源码里的 lib.es5.d.ts,你会看到大量的同名 interface 声明——比如 ArrayStringNumber 的接口分散在多个文件里,通过声明合并拼成一个完整的类型。这是 TypeScript 编译器团队在用的模式,也是声明合并在”维护大型类型定义”上的价值所在。

不正当场景:日常业务代码中的类型拼图

// ❌ 不良实践:把类型的各个部分散落在不同地方
// user-types.ts
interface User {
  id: number;
  name: string;
}

// order-types.ts
interface User {
  orders: Order[];
}

// payment-types.ts
interface User {
  paymentMethods: PaymentMethod[];
}

上面这种写法虽然 TypeScript 不会报错,但维护起来是噩梦——新人要理解 User 的完整形状,需要翻遍整个项目。任何一个属性的类型改了,排查影响面都极其痛苦。

好的做法是一个 interface 在一个地方定义完整

// ✅ 最佳实践:User 的完整定义集中在一个文件里
// user-types.ts
interface User {
  id: number;
  name: string;
  orders: Order[];
  paymentMethods: PaymentMethod[];
}

如果你真的需要通过继承来组织类型(比如”基础用户”和”扩展用户”),用 extends

interface BaseUser {
  id: number;
  name: string;
}

interface FullUser extends BaseUser {
  orders: Order[];
  paymentMethods: PaymentMethod[];
}

extends 明确表达了”FullUser 是基于 BaseUser 的扩展”,类型之间的关系一目了然。而声明合并是隐式的——你读到一个 interface User,不知道另外还有多少个同名的声明藏在项目的其他角落。

小结

  • 声明合并让编译器自动合并同名的 interface、namespace、enum 声明。
  • interface 合并时成员累加,非函数成员冲突报错,函数成员变成重载。
  • namespace 和 enum 也可以合并,但在现代 TS 项目中极少用到。
  • class 可以和同名 interface 合并,但 interface 只加类型声明不提供实现。
  • 声明合并的正途:扩展第三方库的类型、标准库内部使用。
  • 声明合并的陷阱:不要在日常业务代码中依赖它来拼凑类型。用 extends 或组合来组织复杂类型,显式永远好于隐式。

到这里,关于 TypeScript 对象和接口的核心内容就全部讲完了。你已经有能力用 interface 和 type 精确描述项目里的各种数据结构。接下来可以继续学习类(class)和泛型(generics)——它们会和接口产生非常有趣的化学反应。