TypeScript Declaration Merging Explained
Declaration merging lets TypeScript combine multiple declarations of the same name into one — the mechanism behind extending interfaces and global types.
Declaration merging is TypeScript’s rule that when you declare the same named interface, namespace, or module more than once, the compiler combines them into a single definition instead of throwing a duplicate-identifier error. It’s how libraries let you extend built-in types — like adding a property to Window or augmenting Express’s Request object — without editing the library’s source.
The core rule: interfaces merge, types don’t
This is the detail that trips people up most. Declare an interface twice, and TypeScript merges the members into one interface:
interface User {
name: string;
}
interface User {
age: number;
}
// Merged automatically:
const u: User = { name: "Ana", age: 30 };
Try the same thing with type, and you get a compiler error — type aliases are not mergeable, by design. This is one of the sharper distinctions between the two, beyond what’s covered in interfaces vs. types: interfaces are open, types are closed. If you need a shape that other code (including third-party code) can extend later, an interface is the only option of the two.
Why this exists: augmenting third-party and global types
Declaration merging isn’t primarily a way to organize your own code — it exists so that ambient, global, and third-party types can be extended without forking the library. Two common patterns:
Extending a global object. Say a script attaches a custom property to window. Rather than casting to any every time you touch it, you merge a declaration into the global scope:
declare global {
interface Window {
myAppConfig: { apiUrl: string };
}
}
// Now this type-checks cleanly, anywhere in the app:
window.myAppConfig.apiUrl;
Extending a library’s types. A common example is adding a custom field to an Express Request after a piece of middleware attaches it:
declare module "express" {
interface Request {
userId?: string;
}
}
Any file that imports this augmentation sees req.userId as a valid, typed property — no as any casts scattered through the codebase.
One subtlety worth knowing: module augmentation only takes effect in files that actually import the module being augmented, either directly or transitively. If your augmentation lives in a standalone .d.ts file that nothing imports, TypeScript may not pick it up as part of the compilation depending on your tsconfig.json settings — which is why augmentation files are often included explicitly via the include or files field, or via a side-effect import, rather than relied on to be discovered automatically.
Namespaces and functions also merge
Interfaces are the most common case, but the same mechanism applies to namespace declarations, and to a function declaration merged with a namespace of the same name (a pattern used to attach static-like properties to a function):
function greet(name: string) {
return `Hello, ${name}`;
}
namespace greet {
export const defaultName = "friend";
}
greet.defaultName; // valid — merged
Two namespace blocks with the same name merge their exported members the same way two interface blocks merge their fields.
Enums merge too, with one caveat
Two enum declarations with the same name merge their members, but only if none of them use computed (non-constant) member initializers — TypeScript needs to resolve all values statically to merge them. This is a narrower case than interface merging and worth remembering mainly because it explains an otherwise-confusing error if you split an enum’s declaration across files that mix const and computed members.
When to actually use it
In application code, reach for declaration merging in exactly one situation: extending a type you don’t own. If it’s your own module, prefer a single, complete interface or type — splitting one type’s definition across multiple declarations for organizational reasons makes it harder to find the full shape at a glance, and most editors won’t jump you straight to every merged fragment.
The one place it belongs in day-to-day work is a .d.ts file — often named something like globals.d.ts or <library>.d.ts — dedicated to global and third-party augmentations, kept separate from your regular source so anyone reading the codebase knows exactly where to look for “type patches” on external code.
A related but distinct scenario is declaring types for a library that ships with no types at all. That’s still a form of ambient declaration, but it’s not merging in the sense discussed here — merging specifically requires TypeScript to already know about a declaration with that name, so it has something to combine your new one with. Declaring a brand-new module’s shape for the first time is simply writing its types, not merging them.
Declaration merging vs generics
It’s worth distinguishing this from TypeScript generics, which solve a different problem: generics parameterize a type so it can adapt to different inputs at each use site, while declaration merging combines separate declarations of the same name into one definition. You’ll often see both in the same augmentation file — a merged interface whose methods are themselves generic.
The takeaway
Declaration merging is TypeScript’s mechanism for combining multiple declarations of the same interface, namespace, or enum name into one definition — and it only applies to interface, not type. Its main real-world use is augmenting global objects and third-party library types without forking their source. Keep it out of your own domain types, where a single complete declaration is easier to read and safer to refactor.
Keep reading
Takina · · 4 min read Structural Typing vs Nominal Typing in TypeScript
Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.
Takina · · 5 min read TypeScript Variance Explained
Variance decides when TypeScript accepts Array<Dog> where Array<Animal> is expected — covariant, contravariant, or invariant, explained with examples.
Takina · · 5 min read Zod vs Yup: TypeScript Schema Validation Compared
Zod infers static TypeScript types directly from its schemas; Yup was built for JavaScript form validation first. How the two approaches differ.