Capítulo 18 de 36

Chapter 18: Declaration Merging

Core Idea

When TypeScript sees two or more declarations with the same name, it doesn't always error — for specific declaration kinds (interfaces, namespaces, and namespace-with-class/function/enum combos) it merges them into one combined definition, a mechanism used to model real JavaScript patterns (functions with static properties, inner classes, patched prototypes) in a type-safe way.

Key Concepts

  • Three declaration "slots": every declaration creates entities in one or more of namespace, type, and value space. class/enum create both type and value; interface/type create only type; function/variable create only value; namespace creates a namespace (and a value if it has runtime members). What merges — and how — depends on which slots the colliding declarations share.
  • Interface merging: two interface X { ... } declarations with the same name combine their members into one interface. Non-function members must be unique or identically typed (a real conflict is a compile error); same-named function members instead merge as overloads of one function, with later-declared interface bodies taking precedence (ordered first) over earlier ones, except that a signature whose parameter is a single string-literal type (not a union) gets bubbled to the very top regardless of declaration order — the standard trick behind DOM types like document.createElement("canvas") returning the exact HTMLCanvasElement overload instead of the generic Element fallback.
  • Namespace merging: same-named namespaces combine their exported members into one namespace. Exported interfaces inside merge per the interface rules above; non-exported members stay visible only within their own original (un-merged) declaration — a function in one namespace block can't see an unexported let from a different block of the same merged namespace, even though both are logically "the same namespace" after merging.
  • Namespace + class/function/enum merging: a namespace block declared immediately after a class, function, or enum of the same name merges into it, modeling patterns JS doesn't have first-class syntax for:
    • Inner classes: namespace Album { export class AlbumLabel {} } after class Album gives Album.AlbumLabel a nested-class-like structure (the inner class must be exported to be visible from the merge, per the namespace visibility rule above).
    • Function + static properties: attaching prefix/suffix-style properties onto a function value (a common vanilla-JS idiom) gets typed safely via a matching namespace block exporting those as values.
    • Enum + static helper methods: a namespace after an enum can add functions that operate on that enum's members, typed as if they were static enum methods.
  • What can't merge: classes can't merge with other classes or with variables — the mixins pattern (composition via multiple base classes) is the recommended workaround for "I want to combine two classes."
  • Module augmentation (declare module "./path" { interface X { ... } }): patches an existing exported type from another module — the standard way to tell TypeScript about a runtime prototype patch (e.g. Observable.prototype.map = function() {...}) that the compiler otherwise has no static knowledge of. Limits: an augmentation can only add to declarations that already exist (no new top-level exports), and it can only target named exports, not default exports.
  • Global augmentation (declare global { interface Array<T> { ... } } inside a module): adds declarations to the global scope from within a module file — same rules/limits as module augmentation, just targeting the global namespace instead of another module's exports.

Code Examples

function buildLabel(name: string): string {
  return buildLabel.prefix + name;
}
namespace buildLabel {
  export let prefix = "Hello, ";
}
  • What it demonstrates: namespace-merged-with-function, the type-safe way to model a JS function that also carries its own properties.
// observable.ts
export class Observable<T> {}

// map.ts
import { Observable } from "./observable";
declare module "./observable" {
  interface Observable<T> {
    map<U>(f: (x: T) => U): Observable<U>;
  }
}
Observable.prototype.map = function (f) { /* ... */ };
  • What it demonstrates: module augmentation telling the compiler about a prototype method added at runtime from a separate file, without touching the original module's source.

Reference Tables

Merge kindRequiresNotes
Interface + interfacesame namefunction members become overloads; string-literal-param signatures bubble to top
Namespace + namespacesame nameexported members merge; unexported members stay scoped to their own block
Namespace + class/function/enumnamespace declared after the othermodels inner classes, function statics, enum statics
Class + classnot allowed — use mixins instead
Module/global augmentationdeclare module "x" / declare globalpatches existing exports only; no new top-level declarations; named exports only

Key Takeaways

  1. Merging is only automatic for interfaces, namespaces, and namespace-with-(class/function/enum) — classes can never merge with each other; reach for mixins there instead.
  2. When patching a third-party module's prototype at runtime, pair it with a declare module augmentation so TypeScript actually knows the new member exists — otherwise the runtime patch and the static types silently diverge.
  3. Remember the visibility trap in merged namespaces: unexported members are invisible even to other blocks of the "same" merged namespace.

Connects To

  • Modules: module specifier resolution rules that declare module "path" augmentation reuses.
  • Namespaces (next reference chapters): the full namespace feature this chapter's merging rules apply to.
  • Mixins: the recommended pattern for class-like composition, since classes themselves can't merge.