Declaration merging and module augmentation

TypeScript · Advanced · 14 min

It's the sanctioned way to extend library types, like adding a user property to an Express request or a field to Window.

TypeScript lets several declarations with the same name combine into one entity — declaration merging.

Interfaces merge: declaring interface Box { width: number } twice with different members gives one interface with all of them. Non-function members must not conflict; methods with the same name become overloads.

Namespaces merge with functions, classes and enums, which lets you attach static-like members to a function or class — the pattern behind many older JavaScript libraries' typings (function $() that also has $.ajax).

The practical payoff is module augmentation: adding to types declared by another module. In a file that is itself a module (it has an import or export), write:

import "express";
declare module "express-serve-static-core" {
  interface Request { user?: { id: string } }
}

You augment the module that actually declares the interface (here Express's internal types package), and you can only add to existing declarations — not add new top-level exports.

Global augmentation works the same way for global types, using declare global { ... } inside a module:

declare global {
  interface Window { analytics: Analytics }
}
export {};

The export {} matters: in a file without imports or exports, declare global is not allowed, and a plain interface Window in such a script file merges globally anyway — confusing.

Augmentations live in .ts or .d.ts files that the project includes. Keep them in one obvious place (e.g. src/types/), because they change types everywhere and are easy to forget. Augmenting built-ins like Array.prototype is possible but pollutes every consumer of your code; libraries should avoid it.

Example

// Interface merging
interface Box { width: number }
interface Box { height: number }
const box: Box = { width: 2, height: 3 };
// Function + namespace merging: a callable with properties
function format(value: number): string {
  return value.toFixed(format.precision);
}
namespace format {
  export let precision = 2;
}
format.precision = 1;
// Global augmentation (this file is a module because of the export below)
declare global {
  interface Array<T> {
    last(): T | undefined;
  }
}
Object.defineProperty(Array.prototype, "last", {
  value: function <T>(this: T[]) { return this[this.length - 1]; },
});

console.log(box.width * box.height, format(3.14159), [1, 2, 3].last());
export {};

Remember

  • Same-named interfaces merge; methods become overloads
  • Namespaces can merge into functions, classes and enums
  • declare module "pkg" augments a package; declare global augments globals
  • Augmentations must be in a module file and included by the project

Watch out for

  • Augmenting the re-exporting module instead of the module that declares the interface, so nothing merges.

Check yourself

  1. You write declare global { interface Window { appVersion: string } } in a file with no imports or exports. What happens?
  2. Two declarations interface A { f(x: string): void } and interface A { f(x: number): void } are merged. What is A['f']?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.typescriptlang.org/docs/handbook/declaration-merging.html