Declaration merging
See how interfaces with the same name join into one type, add your own property to the browser's Window, and learn why type aliases don't merge.
- 01Predict a merged interfaceSay which properties a type has when two interfaces share a name, and which clashes TypeScript reports.
- 02Add to the browser's typesUse
declare globalandinterface Windowto add your own property, and type a custom event the same way. - 03Know what can mergeExplain how a namespace adds names to a function, and why two type aliases with one name are an error.
Two interfaces, one type
An interface gives a name to an object type: interface User { name: string; } says every User has a name that is text. In JavaScript, declaring the same name twice is usually a mistake. For interfaces, TypeScript allows it on purpose, and the two declarations join. You saw this briefly in Type aliases & interfaces; this lesson shows how it works and where it's useful:
interface User { name: string;}interface User { email: string;} const asha: User = { name: "Asha", email: "asha@example.com" };console.log(asha.email);// ^?Lines 1 to 3 declare User with a name. Lines 4 to 6 declare User again, with an email. There is no error: TypeScript joins the two into one User that has both. Line 8 makes asha, a User with a name and an email, and line 9 prints asha@example.com. The query on line 10 asks what email is, and TypeScript answers (property) User.email: string: the email from the second declaration belongs to the one User.
Because there is only one User, an object needs every property from both declarations:
interface User { name: string;}interface User { email: string;} const sam: User = { name: "Sam" };Line 8 makes sam with only a name. TypeScript marks line 8, on purpose here: Property 'email' is missing in type '{ name: string; }' but required in type 'User'. In plain words: the object you wrote has a name and nothing else, but a User must also have an email. Press Edit & run and add email: "sam@example.com" to line 8 to watch the error go away.
User twice. TypeScript joins them into one User with a name and an email, so asha on line 8 needs both.Joining declarations with the same name is called declaration merging. It sounds like a curiosity, but it's how you add your own properties to types you didn't write, like the browser's window.
An office keeps a binder for each customer, with the customer's name on the spine. Anyone who learns something new adds a page. When you open the binder, you read all of its pages together, as one record.
- In real life: A binder with "User" on the spine
- In TypeScript: The interface name,
User - In real life: One person adds a page about the name
- In TypeScript:
interface User { name: string; } - In real life: Someone else adds a page about the email
- In TypeScript:
interface User { email: string; } - In real life: You read every page in the binder together
- In TypeScript: TypeScript uses every property at once
- In real life: Two pages can't disagree about the same item
- In TypeScript: A property named twice must have the same type
Declaration merging means TypeScript joins declarations that share a name into one. Two interfaces named User become one User with the properties of both. Two interfaces or two namespaces can merge; two type aliases or two classes can't.
How two interfaces join
Merging follows three rules:
- Every property from every declaration goes into the one type. Three or more declarations work too.
- A property can appear in more than one declaration, but only with the same type each time.
- The declarations must be in the same place: in one file, or all in the global scope that every file shares. The next section shows the global scope.
Try the first two rules on a settings object. Lines 1 to 3 declare Settings with a theme. Each button puts something different in the second declaration, on line 5. Line 8 makes an object with a theme and a font size.
interface Settings { theme: string;}interface Settings { fontSize: number;} const settings: Settings = { theme: "dark", fontSize: 16 };console.log(settings.theme, settings.fontSize);dark 16fontSize: number;No errorsLine 5 adds fontSize. Settings now has a theme from line 2 and a fontSize from line 5, so the object on line 8 fits, and the code prints dark 16.
Press each button and watch the list under the code. Add fontSize has no errors: the merged Settings has both properties. fontSize as text moves the error to line 8, because the object gives fontSize a number. Repeat theme names theme again with the same type, which is allowed. theme as a number gets Subsequent property declarations must have the same type on line 5: the later declaration disagrees with line 2. The console line never changes, because types are removed before the code runs. You can also press Edit & run and type your own line 5.
Adding your own property to Window
In the browser, window is the global object: it holds the page's global names (the var and the global object lesson covers it). Apps often put their own things on it, like feature flags the server sends with the page. In plain JavaScript you just set a new property. In TypeScript:
window.featureFlags = { newCheckout: true };console.log(window.featureFlags.newCheckout);Line 1 sets window.featureFlags and line 2 reads it. TypeScript marks both lines, on purpose here: Property 'featureFlags' does not exist on type 'Window & typeof globalThis'. TypeScript comes with the browser's types, in a file called lib.dom.d.ts. There, the type of window is everything the Window interface lists, joined with & to every global name (typeof globalThis), an intersection like the ones in the previous lesson. featureFlags is in neither. The code still runs and prints true; TypeScript just hasn't been told about the property.
Window is an interface, so you can declare it again and your declaration joins the browser's:
declare global { interface Window { featureFlags: { newCheckout: boolean }; }} window.featureFlags = { newCheckout: true };console.log(window.featureFlags.newCheckout);// ^?Lines 1 to 5 are a declare global block. declare means "this only describes something; it makes no code". global means the declarations inside go into the global scope, where the browser's Window lives. Lines 2 to 4 declare interface Window with one property, featureFlags, and it merges with the browser's Window. Line 7 sets the flags, and line 8 prints true with no errors. The query on line 9 shows (property) Window.featureFlags: { newCheckout: boolean; }.
interface Window with properties like innerWidth. Your declare global block declares it again, and the two join, so window.featureFlags is allowed.Why the declare global wrapper? In a project, a file with import or export is a module, and most files are (Why modules covers them). This site checks every example as a module too, even without those lines. Names declared in a module stay in that module. Leave the wrapper out and see:
interface Window { featureFlags: { newCheckout: boolean };} window.featureFlags = { newCheckout: true };Lines 1 to 3 make a new Window that only this file can see. The browser's Window is unchanged, so line 5 gets the same error as before: Property 'featureFlags' does not exist. In a project file with no import or export, declarations are global already, so interface Window { … } alone is enough there. You'll often see that in a file named like global.d.ts; a .d.ts file holds only types.
Merging a namespace into a function
In JavaScript, a function is an object, so it can have properties of its own (the Function objects lesson shows how). TypeScript's older way to describe that is a namespace. A namespace is a named group of values, written namespace Shop { … }. Names inside it marked export can be read from outside, like Shop.currency. Namespaces come from before JavaScript had modules. Today modules do their job, but you'll still meet namespaces in libraries' type files.
Namespaces with the same name merge, like interfaces:
namespace Shop { export const currency = "$";}namespace Shop { export function label(price: number) { return currency + price.toFixed(2); }} console.log(Shop.label(3));Lines 1 to 3 make a Shop namespace with an exported currency. Lines 4 to 8 declare Shop again and add a label function. The two join, so line 6 can use currency from the first block, and line 10 prints $3.00. Only exported names join: without export on line 2, line 6 would get Cannot find name 'currency'.
These namespace examples have no Edit & run. A namespace with values is one of the few TypeScript features that turns into JavaScript code instead of being removed, and this site's editor only removes types. Node.js's built-in TypeScript support refuses them for the same reason. They're still checked here, and you'll see the JavaScript the compiler writes below.
A namespace can also merge with a function of the same name. The namespace's exported names become the function's properties:
function formatPrice(price: number) { return formatPrice.currency + price.toFixed(2);}namespace formatPrice { export const currency = "$";} console.log(formatPrice(3));// ^?console.log(formatPrice.currency);Lines 1 to 3 declare a function formatPrice that reads formatPrice.currency. Lines 4 to 6 declare a namespace with the same name and an exported currency. The two merge, so line 2 is allowed. Line 8 prints $3.00. The query on line 9 gets a two-line answer, function formatPrice(price: number): string and namespace formatPrice: one name, two things. Line 10 reads the property and prints $.
formatPrice. You can call it, like line 8, and read the namespace's currency from it, like line 10.Two rules for this merge:
- The namespace comes after the function. Put it first and TypeScript says
A namespace declaration cannot be located prior to a class or function with which it is merged. - Both are in the same file, and only the namespace's exported names become properties.
Here is the JavaScript the TypeScript compiler writes for the example, which you can run:
function formatPrice(price) {
return formatPrice.currency + price.toFixed(2);
}
(function (formatPrice) {
formatPrice.currency = "$";
})(formatPrice || (formatPrice = {}));
console.log(formatPrice(3));
console.log(formatPrice.currency);Lines 1 to 3 are the function, with its types removed. Lines 4 to 6 are the namespace: a function that runs right away and sets formatPrice.currency to "$". The function is an object, so that just adds a property. Line 7 prints $3.00, and line 8 prints $.
In your own code you rarely need a namespace for this. TypeScript understands a property you set on a function in the same place you declare it:
function formatPrice(price: number) { return formatPrice.currency + price.toFixed(2);}formatPrice.currency = "$"; console.log(formatPrice(3));Line 4 sets formatPrice.currency, and TypeScript adds currency to the function's type, so line 2 has no error. This is plain JavaScript once the types are removed, so it runs anywhere. Line 6 prints $3.00.
Why type aliases don't merge
A type alias, written type Settings = { … }, gives a name to one type, finished when you write it. An interface is open: more declarations can add to it. You met this difference in Type aliases & interfaces. Try both with the same two property lists:
interface Settings { theme: string;}interface Settings { fontSize: number;} const settings: Settings = { theme: "dark", fontSize: 16 };console.log(settings.theme, settings.fontSize);dark 16interfaceNo errorsTwo interfaces named Settings join into one type with a theme and a fontSize. The object on line 8 fits, and the code prints dark 16.
Two interfaces join, with no errors. Two type aliases get Duplicate identifier 'Settings' on lines 1 and 4. "Identifier" means a name, so this says the name Settings is used twice. TypeScript then uses only the first Settings, which has no font size, so lines 8 and 9 get errors too. Two names joined with & is the type alias way to build on a type: give the first part its own name, BaseSettings, and make Settings from it with & (the Intersection types lesson covers &).
Settings join into one type. Two type aliases named Settings don't: TypeScript marks both with Duplicate identifier.The table below compares the two. Read each row as a question, then the answer for an interface and for a type alias.
| Question | interface | type alias |
|---|---|---|
| Can you declare the same name twice? | Yes. The declarations join into one type. | No. TypeScript reports Duplicate identifier. |
Can you add a property to the browser's Window? | Yes. Window is an interface, so yours joins it. | No. A type alias can only make a new name. |
| How do you build a bigger type from it? | Declare it again, or write interface B extends A. | Give the bigger type a new name: type B = A & { … }. |
Can it name a union, like "light" | "dark"? | No. An interface always describes an object. | Yes. A type alias can name any type. |
So when a library wants you to add to one of its types, it uses an interface. When you want a type nobody can add to by accident, a type alias does that.
Typing a custom cart event
A shop page can tell its other parts that the cart changed by sending an event on window. A CustomEvent carries extra data in its detail property (the Custom events lesson covers them). Here is a listener and the event, with no types added:
window.addEventListener("cart:updated", (event) => { console.log(`Cart has ${event.detail.count} items`);}); window.dispatchEvent(new CustomEvent("cart:updated", { detail: { count: 3 } }));Line 1 listens for "cart:updated". Line 2 reads event.detail.count. Line 5 sends the event with a count of 3. The code runs and prints Cart has 3 items, but TypeScript marks line 2, on purpose here: Property 'detail' does not exist on type 'Event'. TypeScript doesn't know this event name, so it treats the event as a plain Event, which has no detail.
How does addEventListener know what an event is? The browser's types have an interface, WindowEventMap, that lists event names and their event types. For "click" it says a PointerEvent. WindowEventMap is an interface, so you can merge your own event into it:
declare global { interface WindowEventMap { "cart:updated": CustomEvent<{ count: number }>; }} window.addEventListener("cart:updated", (event) => {// ^? console.log(`Cart has ${event.detail.count} items`);}); window.dispatchEvent(new CustomEvent("cart:updated", { detail: { count: 3 } }));Lines 1 to 5 add "cart:updated" to WindowEventMap. Its type, CustomEvent<{ count: number }>, means a custom event whose detail is { count: number }; the angle brackets fill in the type of detail (the Generic functions lesson explains them). Line 7 listens, and the query on line 8 shows the event is now that CustomEvent. Line 9 reads event.detail.count with no error, and the code prints Cart has 3 items.
This is the pattern behind "libraries let you add to their types": the library ships an interface, and your declaration joins it. Now sort some declarations by what TypeScript says about them:
interface Tea { name: string } interface Tea { price: number }interface Tea { price: number } interface Tea { price: number }interface Tea { price: string } interface Tea { price: number }type Tea = { name: string }; type Tea = { price: number };type Tea = { name: string }; interface Tea { price: number }class Cart {} class Cart {}function formatPrice() {} namespace formatPrice { export const currency = "$"; }namespace formatPrice { export const currency = "$"; } function formatPrice() {}
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
Merging only changes types. Here is a Window property that is declared but never set:
declare global { interface Window { featureFlags: { newCheckout: boolean }; }} console.log(window.featureFlags);Lines 1 to 5 tell TypeScript that window.featureFlags exists, so line 7 has no error. But nothing ever sets it, and the types are removed before the code runs, so line 7 prints undefined. A declaration is a promise you still have to keep.
- "The second interface replaces the first." No. Every declaration's properties are kept, and the one type has all of them.
- "When two declarations disagree, the later one wins." No. A property named twice must have the same type each time. Otherwise the later one gets
Subsequent property declarations must have the same type. - "declare global puts the property on window." No. It only describes the property. Your code still has to set it, or it's
undefined. - "interface Window in any file adds to the browser's Window." No. In a module, it makes a new
Windowfor that file. Wrap it indeclare global. - "Type aliases merge too." No. A second type alias with the same name is a
Duplicate identifiererror. Use an interface, or a new name and&. - "Merging changes the JavaScript that runs." No. Interfaces are removed completely. Only namespaces with values turn into code.
The table below lists what can merge. Read each row as: this pair of declarations with one name, does it merge, and what TypeScript does.
| One name, declared as | Does it merge? | What TypeScript does |
|---|---|---|
| Two interfaces | Yes. | They join into one type with every property from both. |
| Two interfaces with one property of different types | No. | An error on the later one: Subsequent property declarations must have the same type. |
| Two namespaces | Yes. | Their exported names join into one namespace. |
| A function, then a namespace | Yes. | The function gets the namespace's exported names as properties. |
| A namespace, then a function | No. | An error: the namespace must come after the function. |
| Two type aliases | No. | An error on both: Duplicate identifier. |
| A type alias and an interface | No. | An error on both: Duplicate identifier. |
| Two classes | No. | An error on both: Duplicate identifier. |
Practice exercises
Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
Product is declared three times. How many properties must every Product have?
interface Product { name: string;}interface Product { price: number;}interface Product { inStock: boolean;}interface Product { name: string;}interface Product { price: number;}interface Product { inStock: boolean;} const tea: Product = { name: "Tea", price: 3, inStock: true };console.log(Object.keys(tea).length);The three declarations join into one Product with a name, a price, and an inStock. An object with all three fits, and Object.keys(tea).length prints 3.
Two declarations of CartItem, one with a mistake. Which line will TypeScript mark?
interface CartItem {
name: string;
price: number;
}
interface CartItem {
price: string;
quantity: number;
}interface CartItem { name: string; price: number;}interface CartItem { quantity: number;}Line 6 says price is text, but line 3 already said it's a number, so TypeScript marks line 6: Subsequent property declarations must have the same type. Removing line 6 fixes it, because price is already in the first declaration.
Make both errors go away without changing lines 1 and 2. What does the fixed code print?
window.cartCount = 3;console.log(window.cartCount);declare global { interface Window { cartCount: number; }} window.cartCount = 3;console.log(window.cartCount);The declare global block adds cartCount: number to the browser's Window, so lines 7 and 8 have no errors. Line 7 sets the count, and line 8 prints 3.
TypeScript marks lines 1, 4, and 8. Change the declarations so they merge. Which keyword did you use?
type Settings = { theme: string;};type Settings = { fontSize: number;}; const settings: Settings = { theme: "dark", fontSize: 16 };interface Settings { theme: string;}interface Settings { fontSize: number;} const settings: Settings = { theme: "dark", fontSize: 16 };Two interfaces with one name merge, so Settings has a theme and a fontSize, and the object on line 8 fits. Another fix keeps type aliases and gives the first one a new name, joined with &.
A wishlist button sends an event. Make the error on line 2 go away by adding the event to the browser's types. What does the code print?
window.addEventListener("wishlist:added", (event) => { console.log(`Added ${event.detail.name}`);}); window.dispatchEvent(new CustomEvent("wishlist:added", { detail: { name: "Tea" } }));declare global { interface WindowEventMap { "wishlist:added": CustomEvent<{ name: string }>; }} window.addEventListener("wishlist:added", (event) => { console.log(`Added ${event.detail.name}`);}); window.dispatchEvent(new CustomEvent("wishlist:added", { detail: { name: "Tea" } }));Adding "wishlist:added" to WindowEventMap tells addEventListener that this event is a CustomEvent whose detail has a name. Line 8 has no error, and the code prints Added Tea.
TypeScript marks line 2, and the code prints undefined4.00. Give formatPrice a currency of "$". What does the fixed code print?
function formatPrice(price: number): string { return formatPrice.currency + price.toFixed(2);} console.log(formatPrice(4));function formatPrice(price: number): string { return formatPrice.currency + price.toFixed(2);}formatPrice.currency = "$"; console.log(formatPrice(4));Line 4 sets formatPrice.currency, and TypeScript adds currency to the function's type, so line 2 has no error. "$" + (4).toFixed(2) prints $4.00.
Check your understanding
For each question, ask which declarations share a name, and whether that kind of declaration can merge. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What happens when a file declares two interfaces with the same name?
Choose an answer to see the explanation.
Question 2 of 7Which line does TypeScript flag in these two Order interfaces?
Read the code, then predictinterface Order { id: number; paid: boolean; } interface Order { id: string; }Choose an answer to see the explanation.
Question 3 of 7Does the object on line 8 fit the merged Profile?
Read the code, then predictinterface Profile { name: string; } interface Profile { email: string; } const asha: Profile = { name: "Asha" };Choose an answer to see the explanation.
Question 4 of 7Why does
window.cartCount = 3get an error in a TypeScript file?Choose an answer to see the explanation.
Question 5 of 7What does TypeScript say about these two type aliases named Theme?
Read the code, then predicttype Theme = { color: string }; type Theme = { font: string };Choose an answer to see the explanation.
Question 6 of 7What does this code print when it runs in a browser?
Read the code, then predictdeclare global { interface Window { cartCount: number; } } console.log(window.cartCount);Choose an answer to see the explanation.
Question 7 of 7Where must a namespace go to merge with a function of the same name?
Choose an answer to see the explanation.
Key takeaways
- Interfaces with the same name join into one type with every property from each declaration.
- A property named in two declarations must have the same type, or the later one gets an error.
- To add to a global type like
WindoworWindowEventMapfrom a module, declare it again insidedeclare global. - A declaration only describes. Your code still has to set the property it promises.
- A namespace after a function with the same name gives the function properties. Namespaces with values become real code.
- Two type aliases or two classes with one name don't merge: TypeScript marks both with
Duplicate identifier.
Remember the one-liner.
Declare an interface again, and TypeScript adds your properties to the same type.
Coming next: Classes in TypeScript. It types a class's fields, constructor, and methods, and shows the parameter-property shortcut.