TypeScript’s declaration merging is how you can add properties to existing types without rewriting them, a surprisingly flexible feature for extending interfaces, classes, and even namespaces.
Let’s see it in action. Imagine you have a library that defines an HTMLElement interface. You want to add a custom property, say data-custom-id, to all HTMLElement instances in your application.
// In your application code
interface HTMLElement {
"data-custom-id"?: string;
}
// Now you can use it
const myDiv: HTMLElement = document.createElement('div');
myDiv.setAttribute('data-custom-id', 'unique-element-123');
console.log(myDiv['data-custom-id']); // Output: unique-element-123
This works because TypeScript, when it encounters multiple declarations for the same type name in the same scope, automatically merges them. This is incredibly useful for augmenting types from external libraries or even built-in browser APIs.
The core problem declaration merging solves is the inability to directly modify types defined elsewhere, like in node_modules. Without it, you’d be stuck with the original definitions or forced into complex type casting. Declaration merging provides a clean, type-safe way to extend these types.
Internally, TypeScript’s compiler sees these separate declarations for HTMLElement and combines their members into a single, unified type. For interfaces, properties are added. For classes, methods and static members are added. For namespaces, members are added, and if they are also interfaces or classes, their rules apply.
You can extend interfaces, classes, and namespaces.
Extending Interfaces
This is the most common use case. As shown above, you can add new properties or methods to an existing interface.
// Original interface (e.g., from a library)
interface User {
id: number;
name: string;
}
// Your extension
interface User {
email?: string;
isActive: boolean;
}
const newUser: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
isActive: true,
};
Here, newUser correctly has all properties from both declarations.
Extending Classes
You can add new methods or properties to existing classes. This is often used to add utility methods to classes you don’t own.
// Original class (e.g., from a library)
class Product {
constructor(public name: string, public price: number) {}
}
// Your extension
interface Product {
getFormattedPrice(): string;
}
// Implement the new method
Product.prototype.getFormattedPrice = function() {
return `$${this.price.toFixed(2)}`;
};
const myProduct = new Product("Widget", 19.99);
console.log(myProduct.getFormattedPrice()); // Output: $19.99
This adds a method to the Product class’s prototype, making it available to all instances.
Extending Namespaces
Namespaces can also be merged, allowing you to add members to an existing namespace.
// Original namespace
namespace App {
export interface Settings {
theme: string;
}
}
// Your extension
namespace App {
export interface Settings {
fontSize: number;
}
export function getDefaultSettings(): Settings {
return { theme: "dark", fontSize: 14 };
}
}
const settings: App.Settings = App.getDefaultSettings();
console.log(settings); // Output: { theme: 'dark', fontSize: 14 }
This allows you to logically group related functionality and types.
The most surprising part of declaration merging is how it handles conflicts between interfaces and classes with the same name. If you declare an interface and a class with the same name, TypeScript will merge them, resulting in a type that is both the interface and the class. The interface members become part of the class’s public API, and the class members are available on instances. This means you can add methods to an interface that are then implemented by a class of the same name, and TypeScript will understand this relationship.
The next concept you’ll run into is how to use declaration merging effectively with module augmentation for global objects.