Declaration merging

Combines two same-named declarations into one definition across interfaces, namespaces, enums and more.

Signature
// Each declaration creates some combination of namespace, type and value:
//
//   Declaration   Namespace   Type   Value
//   Namespace         X         X      X
//   Class                       X      X
//   Enum                        X      X
//   Interface                   X
//   Type Alias                  X
//   Function                           X
//   Variable                           X
//
// Two declarations merge when the entities they create do not collide.
  • Added in TypeScript 1.0

Parameters

ParameterTypeDescription
$interface + interfacemergeMembers combine. Non-function members must be unique or identically typed; same-named function members become overloads.
$namespace + namespacemergeExported members combine. Non-exported members stay private to their original block.
$namespace + class / function / enummergeAdds static members or inner types to a value. The namespace must be declared after the class or function it merges with.
$class + class, class + variabledisallowedClasses cannot merge with other classes or with variables. Use mixins to achieve a similar effect.
$type aliasdisallowedType aliases never merge — a repeated alias name is a duplicate-identifier error.

Errors

Errors at compile time when merged declarations give a non-function member incompatible types, when a class is merged with another class or a variable, and when a type alias name is reused.

Changelog

  • PHP 1.8Module augmentation and `declare global` extended merging across file and module boundaries, with the restriction that an augmentation may only patch existing declarations.
  • PHP 2.2Default exports cannot be augmented, because augmentation works by exported name and `default` is a reserved word.

Declaration merging is the rule that makes TypeScript able to describe JavaScript that other people wrote. Two declarations with the same name in the same scope are combined rather than rejected.

What merges with what

Each declaration form creates some combination of a namespace, a type and a value. Two declarations can merge when the things they create do not collide. So interfaces merge with interfaces, namespaces merge with almost anything, and a namespace can merge with a class, function or enum to attach statics. Classes cannot merge with other classes, and type aliases never merge at all.

Overload ordering, which surprises people

When merged interfaces declare the same method, the declarations become overloads and later declarations sort first. On top of that, signatures whose parameter is a single string literal type bubble to the very top. That is how document.createElement("div") resolves to HTMLDivElement instead of matching the general string overload declared elsewhere.

What trips people up

Merging is silent. A duplicated interface name — a stale copy, a bad merge, a typo — quietly widens your type rather than erroring, and the extra members only surface as a runtime undefined. Augmentations can also only patch: you cannot add a new top-level export to someone else's module, and you cannot augment a default export.

Example

Example · typescript
interface Box { height: number }
interface Box { width: number }   // merges, does not replace

function buildLabel(name: string): string {
  return buildLabel.prefix + name;
}
namespace buildLabel {            // merges statics onto the function
  export let prefix = "Hello, ";
}

enum Color { Red = 1, Green = 2 }
namespace Color {                 // merges a helper onto the enum
  export function mix(): number { return Color.Red | Color.Green; }
}

const b: Box = { height: 5, width: 6 };
console.log(b.height + b.width, buildLabel("Sam"), Color.mix());
// 11 Hello, Sam 3

When to use it

  • Add a method to a library's exported class from your own module augmentation.
  • Attach typed configuration statics onto an exported function.
  • Extend a plugin system's options interface from each plugin's own file.
  • Track down why an interface has members that appear nowhere in its declaration.

Frequently asked questions

What is declaration merging in TypeScript?

It is the compiler combining two or more declarations of the same name in the same scope into a single definition. Interfaces and namespaces merge freely, namespaces can also merge with classes, functions and enums, while classes and type aliases cannot merge.

Can I merge a type alias with an interface?

No — type aliases do not participate in declaration merging, so reusing the name gives a duplicate-identifier error. If a type needs to be extensible by other files or by consumers, it has to be declared as an interface.

Why did my interface gain properties I never declared?

Another declaration with the same name exists in the same scope and merged into yours — commonly a stale duplicate file, a global .d.ts, or a library's augmentation. Search the project for the interface name, or convert the files into modules so the declarations no longer share a global scope.

More examples

Overload ordering after an interface merge

Overload resolution picks the first matching signature, so ordering decides the result. Because the literal-typed signatures are hoisted above create(tag: string), passing "div" gets the specific return type rather than the general one. This is exactly the mechanism behind lib.dom.d.ts's createElement, and it is why an interface merged in later can change what an existing call resolves to.

Example · typescript
interface Doc {
  create(tag: any): { kind: string };
}

interface Doc {
  create(tag: "div"): { kind: "div" };
  create(tag: "span"): { kind: "span" };
}

interface Doc {
  create(tag: string): { kind: "element" };
  create(tag: "canvas"): { kind: "canvas" };
}

// Effective order after merging — later groups first, and
// single-string-literal signatures bubble above the rest:
//   create(tag: "canvas")
//   create(tag: "div")
//   create(tag: "span")
//   create(tag: string)
//   create(tag: any)

declare const doc: Doc;
const el = doc.create("div");   // typed { kind: "div" }, not "element"
console.log(el.kind);

A silent merge that produces undefined at runtime

No duplicate-identifier error is reported, because interfaces merge by design. User silently gains displayName, so every object literal must now supply it while any value produced by a cast or JSON.parse will not. Renaming one of the interfaces, or moving them into modules so they no longer share a scope, is the fix.

Example · typescript
// user.ts
interface User {
  id: string;
  name: string;
}

// legacy-user.ts — an old copy nobody deleted, same global scope
interface User {
  displayName: string;
}

// app.ts
const u: User = { id: "u1", name: "Ada", displayName: "Ada L." };
// Omitting displayName is now an ERROR, even though user.ts never mentions it.

function greet(user: User): string {
  return `Hi ${user.displayName}`;
}

console.log(greet(u));
// Hi Ada L.

// The reverse bites harder: JSON parsed from an API and cast to User
// type-checks fine but yields "Hi undefined".

Discussion

  • Be the first to comment on this lesson.