Declaration Merging

Reviewed & published by Brayan K

Declaration merging is TypeScript's rule that two or more declarations sharing the same name are automatically combined into one definition, letting you extend interfaces, attach statics to functions, and augment library or global types.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of declaration merging like adding pages to a shared binder. Two people each bring a few pages labelled "Box"; instead of fighting over the label, TypeScript files all the pages under "Box" so the binder now contains everyone's contributions. That's why a library can ship a base Request interface and your code can quietly slip in an extra user page — same label, combined contents.

1. Function + Namespace Merging

When a function and a namespace share a name, TypeScript merges them: the function stays callable and also gains the namespace's exported members as static properties. This is the type-safe way to describe a callable value that carries helpers — like jQuery's $ being both a function and a bag of utilities. At runtime it's simply a function object with extra properties attached.

// TWO declarations with the SAME name merge into one. A function declaration
// and a namespace of the same name combine — the function gains static members:

function greet(name: string): string {
  return greet.prefix + name;
}
namespace greet {
  export let prefix = "Hello, ";
  export function shout(name: string): string {
    return greet(name).toUpperCase();
  }
}

greet("Ada");        // "Hello, Ada"  — callable
greet.prefix;        // "Hello, "     — static property
greet.shout("Ada");  // "HELLO, ADA"  — static method
// Declaration merging is a COMPILE-TIME feature, but the runtime behaviour it
// mirrors is real. The classic "function + namespace" merge produces ONE value
// that is both CALLABLE and carries extra static properties.

// A callable object with statics — exactly what TS lets you describe with merging:
function greet(name) {
  return greet.prefix + name;
}
greet.prefix = "Hello, ";          // a "namespace" property bolted onto the function
greet.shout = (name) => greet(name).toUpperCase();

console.log(greet("Ada"));         // Hello, Ada
console.log(greet.prefix);         // Hello,
console.log(greet.shout("Ada"));   // HELLO, ADA

// Merging interfaces is just "all members combine" — two shapes become one value:
const a = { id: 1 };
const b = { name: "widget" };
const merged = { ...a, ...b };     // one value with BOTH sets of members
console.log(merged.id, merged.name); // 1 widget

2. Interface Merging & Augmenting Globals

Two interfaces with the same name merge their members into one larger interface — there's no duplicate error, unlike with type aliases. This is the engine behind global augmentation: you reopen a built-in interface like Window and add your own properties so the compiler knows about values you attach at runtime.

// Two INTERFACES with the same name MERGE — their members are combined.
// (Two type aliases with the same name would instead be a duplicate error.)
interface Box {
  width: number;
}
interface Box {
  height: number;          // merged in — Box now has BOTH
}

// Box is now { width: number; height: number }
const b: Box = { width: 10, height: 5 }; // ✅ must satisfy the MERGED interface
// const bad: Box = { width: 10 };       // ❌ Property 'height' is missing
// AUGMENTING a global. In TS you write
//   declare global { interface Window { appConfig: Config } }
// to TELL the compiler about a property your code adds at runtime.
// The RUNTIME part is simply the assignment — here we use a plain object as "window".

const windowLike = {}; // pretend this is the global 'window'

// Some setup code adds a property at runtime:
windowLike.appConfig = { theme: "dark", version: 3 };

// Now any code can read it (and TS would know its type thanks to the augmentation):
console.log(windowLike.appConfig.theme);   // dark
console.log(windowLike.appConfig.version); // 3

// Express does the SAME: middleware sets req.user, and a global augmentation
// makes TypeScript aware of it. Runtime = just an assignment:
const req = { headers: {} };
function authMiddleware(req) { req.user = { id: 42, name: "Ada" }; }
authMiddleware(req);
console.log(req.user.id, req.user.name); // 42 Ada

3. Module Augmentation (Express & Beyond)

To extend types that live in another package, use module augmentation. Wrapping a declaration in declare module "express" (or declare global { namespace Express { ... } }) merges new members into that library's interfaces. The canonical use is teaching TypeScript about req.user set by your auth middleware.

// MODULE AUGMENTATION adds members to types declared in ANOTHER module.
// The famous example: teaching TypeScript about req.user on Express.
import "express";

declare global {
  namespace Express {
    interface Request {
      user?: { id: number; name: string }; // merged into Express's Request
    }
  }
}

// Now every handler sees req.user as fully typed:
// app.get("/me", (req, res) => res.json(req.user)); // ✅ req.user is known

// To augment a non-global module, target it by name instead:
declare module "express" {
  interface Request {
    requestId?: string;
  }
}

🎯 Your Turn

Build your own function + namespace merge: a callable counter() that bumps a count, plus a .reset() static. Fill in the two blanks marked ___, then run it.

// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Build a "function + namespace" merge: a callable counter that ALSO
// carries a .reset() static and a .count property.

function counter() {
  counter.count = counter.count + 1;   // bump the static count
  return counter.count;
}
counter.count = 0;                       // the "namespace" property
counter.reset = () => { counter.count = ___; }; // 👉 reset back to 0

console.log(counter());   // 1
console.log(counter());   // 2
counter.reset();
console.log(counter.___); // 👉 read the 'count' property -> 0

// ✅ Expected output:
//    1
//    2
//    0

Common Errors (and the fix)

Pro Tips

📚 Keep Learning

Mini-Challenge: A Money Formatter With Statics

No blanks this time — just a brief and a starting outline. Build the function + namespace merge yourself, run it, and check your output against the example in the comments.

// 🎯 MINI-CHALLENGE: a function + namespace merge for a money formatter.
// 1. Make money(amount) return money.currency + amount.toFixed(2).
// 2. Attach money.currency = "£" and money.withCurrency(sym, amount)
//    that uses a given symbol instead of the default.
// 3. Log money(5), money.currency, and money.withCurrency("$", 5).
//
// ✅ Expected output:
//    £5.00
//    £
//    $5.00

// your code here

🎉 Lesson Complete

Practice quiz

What is declaration merging in TypeScript?

  • Combining two .ts files into one
  • Merging types at runtime with Object.assign
  • Two or more declarations with the SAME name combined into one definition
  • Importing everything from another module

Answer: Two or more declarations with the SAME name combined into one definition. Declaration merging combines same-named declarations (interfaces, namespaces, etc.) into a single definition.

What happens when you declare two interfaces with the same name?

  • Their members are combined into one interface
  • A duplicate-identifier error
  • The second replaces the first
  • Only the first is used

Answer: Their members are combined into one interface. Same-named interfaces merge: their members are combined, unlike type aliases.

Can two type aliases with the same name be merged?

  • Yes, just like interfaces
  • Only if they are in different files
  • Only with declare global
  • No, it is a duplicate-identifier error

Answer: No, it is a duplicate-identifier error. Type aliases do NOT merge; declaring the same 'type' twice is a duplicate-identifier error.

When a function merges with a same-named namespace, the result is:

  • Two separate values
  • A callable value that also carries static members
  • Just a namespace
  • A compile error

Answer: A callable value that also carries static members. The merged value stays callable as a function and gains the namespace's exports as statics.

Which construct do you use to add a property like req.user to Express's Request type?

  • declare global with namespace Express { interface Request { ... } }
  • A type alias
  • Object.defineProperty
  • A class

Answer: declare global with namespace Express { interface Request { ... } }. Global declaration merging via 'declare global { namespace Express { interface Request { user?: User } } }' adds the typing.

What does declaration merging add at runtime?

  • A reverse-mapped object
  • A new constructor
  • Nothing — it only adds type information
  • A frozen prototype

Answer: Nothing — it only adds type information. Merging is purely type-level; you still must assign the actual property (e.g. greet.prefix) at runtime.

Why must an augmentation file be a module (have an import or export)?

  • For faster compilation
  • Otherwise declare global is not picked up and the augmentation is ignored
  • To enable strict mode
  • To allow default exports

Answer: Otherwise declare global is not picked up and the augmentation is ignored. Global augmentations only work inside module files; add 'export {}' so the file is treated as a module.

Which keyword targets a specific third-party module for augmentation?

  • declare global
  • import type
  • namespace
  • declare module "express"

Answer: declare module "express". Module augmentation uses 'declare module "lib" { ... }' to merge members into another package's types.

For a public, library-facing shape that consumers may extend later, you should prefer:

  • A type alias
  • An interface (it is open and mergeable)
  • A const
  • An enum

Answer: An interface (it is open and mergeable). Interfaces are open, so a library and its consumers can both contribute members via merging.

Which combination CANNOT merge?

  • Two same-named interfaces
  • A function and a namespace
  • Two same-named type aliases
  • An enum and a namespace

Answer: Two same-named type aliases. Interfaces, function+namespace, and enum+namespace merge; two type aliases of the same name do not.

Continue this course

Frequently asked questions

What exactly is declaration merging?

It is TypeScript's rule that two or more declarations with the SAME name in the same scope are combined into a single definition. The most common case is two interfaces with the same name merging their members, but functions, namespaces, classes, and enums can merge with each other in specific combinations too.

Can I merge two type aliases the same way I merge interfaces?

No. Only interfaces (and namespaces, and a few function/class/enum combinations) merge. Declaring the same 'type' alias twice is a duplicate-identifier error. If you need open-ended, augmentable types — for example to let other code extend your shape — use an interface, not a type alias.

How do I add a property to req on Express or to Window?

Use global declaration merging. Write 'declare global { namespace Express { interface Request { user?: User } } }' in a .d.ts (or any module file) to add req.user, or 'declare global { interface Window { appConfig: Config } }' for window.appConfig. You're merging your members into the library's existing interface; the runtime assignment is still your job.

What is module augmentation and how is it different?

Module augmentation uses 'declare module "some-lib" { ... }' to merge new members into types exported by another module. It's how plugins extend a library's types (e.g. adding a method to a third-party class). Global augmentation ('declare global { ... }') targets the global scope instead of a specific module.

Related lessons