Topic 16.3
Declaration Merging and Advanced Augmentation
In one line
When two declarations share a name, TypeScript sometimes merges them into one. Interfaces add members, namespaces attach values to functions, classes and enums, and module augmentation lets you add to a library's types without touching its files.
Think of it like this
A school has one class register, but two teachers write in it. The maths teacher adds her column, the science teacher adds his. Nobody crosses out anything; the register simply ends up with both columns. Declaration merging is the same: write the same name twice in the right way, and the compiler combines the two into one.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Declaration merging
- Combining two declarations that share a name into one definition.
- Overload
- One of several call signatures for the same function name.
- Namespace
- A named container for values and types. It compiles to an object.
- Ambient
- A declaration that only describes something that exists elsewhere. It produces no JavaScript.
- Global augmentation
- Adding to global types (like Array or Window) from inside a module using declare global.
- Module augmentation
- Adding to the types exported by another module using declare module "name".
- Script vs module file
- A file with no import or export is a script (its names are global). One with an import or export is a module (its names are local).
- .d.ts
- A declaration file: types only, no code.
Step by step
01Same name, written twice
Declare interface Box with width, then interface Box again with height. A Box value must have both. Nothing is overwritten.
For two methods with one name the signatures become overloads. For two properties with one name the types must be identical, or the compiler reports TS2717.
interface Box { width: number }
interface Box { height: number }
const b: Box = { width: 2, height: 3 }; // both members required02A function that also has properties
Write the function first, then a namespace with the same name. The namespace's exports become properties of the function. Both the type and the compiled JavaScript agree, so greet.suffix = "?" really changes later calls.
The same trick works after a class (static helpers) and after an enum (helper functions such as Color.parse).
03Add a type, then add the code
An interface merged into a class adds members to its instance type only. The runtime work (the actual function) is yours. Declare, then implement.
04Reach outside your own files
declare global changes global types. declare module "x" changes the types of module x. Both live in a .d.ts or .ts file that is itself a module.
05Check the result, don't assume
Add a use of the new member and one with a typo. The typo must be an error. Remove the augmentation and the use must fail. That two-sided check proves the augmentation, and nothing else, is what makes it compile.
Try it yourself
- 1
Merge a third time
In the first example, add
interface Box { depth: number }and predict which line of the example becomes an error. It isconst b: Box = ...(TS2741, "Property 'depth' is missing"), becauseBoxnow requires all three members. Fix it by addingdepth: 1to the object. - 2
Reorder the namespace
In the second example, move
namespace greet { ... }abovefunction greet. Predict the error before you look. You should seeTS2434: A namespace declaration cannot be located prior to a class or function with which it is merged.on the namespace line, and the console showsUncaught SyntaxError: Identifier 'greet' has already been declared. - 3
Spot the missing implementation
In the class-and-interface example, delete the three lines that assign
Counter.prototype.reset. The Types panel stays clean. Run it and read the console.
Code & diagrams
Box has both members. The two log declarations are now two overloads, so the result type follows the argument. Two type aliases with one name, and two properties with one name but different types, are errors.
Console
(nothing was printed)Expected console output
6typeof greet means the function and its namespace members together. The namespace compiles to real properties, so changing greet.suffix changes the next call. Each namespace comes after its function, class or enum.
Console
(nothing was printed)Expected console output
Hello, Asha!
Hello, Ravi?
Untitled
1 RedThe interface adds reset to the instance type. The class never defined it, so the assignment on the prototype is what makes the call work. Delete those three lines and the checker still says c.reset() is fine, but running it crashes.
Console
(nothing was printed)Expected console output
0export {} makes the file a module, which declare global needs. The declarations only tell the checker the members exist: the two assignments below them make that true at runtime.
Console
(nothing was printed)Expected console output
3 1.2.0
A// theme.d.ts: a module file (it has an import), so this MERGES with @types/react
import "react";
declare module "react" {
interface CSSProperties {
"--accent"?: string;
}
}
// App.tsx
import type { CSSProperties } from "react";
export const ok: CSSProperties = { color: "red", "--accent": "#0a7" };
export const bad: CSSProperties = { "--acent": "#0a7" };
// tsc (6.0 and 7.0), with theme.d.ts in the project:
// App.tsx(4,37): error TS2353: Object literal may only specify known properties, and '"--acent"' does not exist in type 'CSSProperties'.
//
// App2.tsx, in a project WITHOUT theme.d.ts
import type { CSSProperties } from "react";
export const only: CSSProperties = { "--accent": "#0a7" };
// tsc (6.0 and 7.0): without theme.d.ts, the correct line itself fails:
// App2.tsx(2,38): error TS2353: Object literal may only specify known properties, and '"--accent"' does not exist in type 'Properties<string | number, string & {}>'.// bad1.d.ts: NO import or export, so this is a script: it declares a new module "react"
declare module "react" {
interface CSSProperties {
"--accent2"?: string;
}
}
// use1.tsx
import type { CSSProperties } from "react";
export const a: CSSProperties = { color: "red", "--accent2": "#0a7" };
export const b: CSSProperties = { color: "red" };
// tsc (6.0 and 7.0):
// use1.tsx(2,35): error TS2353: Object literal may only specify known properties, and 'color' does not exist in type 'CSSProperties'.
// use1.tsx(3,35): error TS2353: Object literal may only specify known properties, and 'color' does not exist in type 'CSSProperties'.
//
// Adding import "react"; at the top of bad1.d.ts fixes both lines.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Two classes with one name
In the class-and-interface example, add class Dog {} twice at the end of the code, on two lines.
Break #2
Forget the implementation
In the class-and-interface example, delete the three lines that assign Counter.prototype.reset (the Counter.prototype.reset = function ... block).
Myth vs fact
Myth
Declaring a member in an interface adds it to the class at runtime.
Fact
Merging is purely a compile-time idea. The interface says the member exists; only your code can make it exist.
Myth
declare module "x" always augments module x.
Fact
Only inside a file that is a module. In a script file it declares a brand-new module and hides the real one.
Myth
type and interface merge the same way.
Fact
Only interfaces merge. Two type aliases with one name are a duplicate identifier, and an alias can't be augmented by a library user.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Merging is the reason libraries expose extension points as interfaces:
Express.Request,Window,ProcessEnv, React'sCSSPropertiesandHTMLAttributesare interfaces so that applications can add fields. Atypealias in the same position would close the door. - ▸
A call is matched against overloads in order, and when interfaces merge, the signatures of a later declaration are placed before those of an earlier one. Checked on both compilers: with
interface Ov2 { f(x: string): "first" }followed byinterface Ov2 { f(x: string): "second" }, the callov2.f("a")has the type"second". Within one declaration the written order is kept. When two signatures could both accept an argument, confirm with a// ^?query which one wins instead of reasoning about declaration order. - ▸
Namespaces still have a role in
.d.tsfiles and for the function-plus-properties pattern, but witherasableSyntaxOnly(Topic 12.5) a namespace that holds runtime values is refused. Prefer plain objects and modules for new code.
Remember this
- 1
The problem this solves: Real JavaScript code is full of things that the type system cannot describe in one declaration: a function that also carries properties, a class that other code extends, a library whose types you want to add to without editing its files. Merging lets you describe those honestly, in pieces. (Recall from Topic 3.4: two interfaces with the same name merge, and two type aliases with the same name do not.) Interfaces merge. Two
interface Boxdeclarations in the same scope become one interface that has the members of both. If both declare the same method, the result has overloads:log(x: string): stringandlog(x: number): numberbecome two signatures, solg.log("a")is astringandlg.log(1)is anumber. If both declare the same property with different types, that is an error (TS2717). Atypealias cannot merge: twotype T1declarations are a duplicate identifier (TS2300). This is the practical reason libraries publish interfaces for things users may extend. - 2
Namespaces merge with functions, classes and enums. A
namespacewith the same name as a function attaches properties to it: the function becomes an object you can also readgreet.prefixfrom. The namespace must come after the function or class in the same file. This describes a very common JavaScript pattern (a function with properties, a class with static helpers, an enum with parsing functions), and the merged result is real JavaScript at runtime, because a namespace compiles to an object. - 3
Classes merge with interfaces. If you declare a
class Counterand aninterface Counterwith the same name, the interface's members are added to the instance type. TypeScript does not create the implementation: addingreset(): voidto the interface only says that the method exists. You must assign it yourself (Counter.prototype.reset = ...; theprototypeis the one object JavaScript shares between all instances of a class, which is where their methods live). If you forget, the type says the method exists and the call crashes. That is why a lint rule,no-unsafe-declaration-merging, exists (Topic 16.5). Two classes with the same name never merge: that is a duplicate identifier. - 4
Global augmentation adds to types that exist everywhere (Recall from Topic 11.5: a
declare globalblock describes something the environment provides). Inside a file that is a module (it has animportorexport),declare global { ... }can add members tointerface Array<T>,interface Windowand other globals. You still have to write the runtime implementation yourself. TypeScript trusts you thatArray.prototype.lastexists because you declared it; changing built-in prototypes is a risky practice, so treat this as a tool for declaring things the environment really provides. - 5
Module augmentation adds to a library's own types. In a file that is a module,
declare module "react" { interface CSSProperties { "--accent"?: string } }merges with the realCSSPropertiesinterface in@types/react. Two rules matter. First, the file must have at least oneimportorexport: without it,declare module "react"is read as a brand-new module declaration that replaces the real one, and the real members disappear. Second, you can only add or merge: you cannot rename or remove existing members, and you cannot merge into atypealias. - 6
How to augment safely: put augmentations in a clearly named
.d.tsfile (for exampletypes/react-augment.d.ts) that is included by yourtsconfig; start the file withimport "react"orexport {}so it is a module; augment the interface the library exposes; keep to additions (optional members are safest, since existing code that doesn't set them still compiles); and mention the augmentation in your team's docs so a future reader does not wonder where the extra property came from. The non-live examples below show the real before and after.
Explain it without notes
What is declaration merging, and which declarations merge?
What happens when two merged interfaces both declare a method with the same name? And a property?
How do you safely add a property to a library's type?
Why can adding a member through an interface merged into a class crash at runtime?
Practice
Augment the global Window interface so that window.theme is "light" | "dark", then assign it. Show the live version of the file.
Write a function counter() that returns 0 plus a namespace so that counter.calls is a number you can read, and call both.
Trade-offs
- ↔
Augmentation is powerful, but it changes types far from where you use them. A reader of
style={{ "--accent": x }}may not know where the property came from. Keep augmentations in one clearly named folder and document them. - ↔
Merging classes with interfaces and patching built-in prototypes gets you compact code at the price of types that promise more than the code delivers. Prefer helper functions or subclasses unless an API truly needs the patch.
Done when you can
I can name which declarations merge and which are duplicate identifiers.
I can predict the overloads that merged interface methods produce.
I can attach properties to a function, class or enum with a namespace, and put it in the right order.
I can explain why a script file's
declare modulehides the real module.I can augment a library's interface safely and prove it with a two-sided check.