Declaration Merging
Declaration merging is one of TypeScript's most distinctive features. It lets you define the same name more than once and have the compiler merge those definitions into a single entity. Mastering declaration merging unlocks powerful patterns for extending third-party types, augmenting modules, and building layered plugin APIs.
What Is Declaration Merging?
TypeScript treats multiple declarations of the same name in the same scope as a single, combined type. This was designed to model the loose, additive nature of JavaScript, where objects grow new properties at runtime and libraries attach methods to global objects.
There are three categories of declarations:
- Namespace — creates a namespace value and type
- Type — creates an interface or type alias
- Value — creates a variable, function, class, or enum
What can merge with what is governed by a fixed compatibility table.
First Declaration | Merges With | Result |
|---|---|---|
Interface | Interface | Single merged interface |
Namespace | Namespace | Single merged namespace |
Namespace | Class | Static members added to class |
Namespace | Function | Properties added to function |
Namespace | Enum | Extra members added to enum |
Type alias | Type alias | ERROR — no merging |
Interface Merging
Interface merging is the most common form. When two interfaces share the same name in the same scope, TypeScript combines their members as if you had written one big interface.
interface Window {
title: string;
}
interface Window {
location: Location;
}
// TypeScript sees both as one:
// interface Window {
// title: string;
// location: Location;
// }
const win: Window = {
title: 'My App',
location: {} as Location,
};Member Ordering in Merged Interfaces
Non-function members from the later interface are placed after those from the earlier one. For overloaded function members, later declarations are placed before earlier ones — TypeScript tries the most-recently-added overload first, so more-specific overloads should go in later declarations.
interface Formatter {
format(x: number): string;
}
interface Formatter {
format(x: string): string; // placed BEFORE the number overload
}
// Effective merged signature order:
// format(x: string): string;
// format(x: number): string;
declare const f: Formatter;
f.format('hello'); // string overload matched first
f.format(42); // number overload matched secondNamespace Merging
Namespaces merge their exported members. Private (unexported) members are not visible outside their own declaration block. This is frequently used to attach utilities directly onto a type namespace.
namespace Validation {
export interface StringValidator {
isAcceptable(s: string): boolean;
}
}
namespace Validation {
export const emailPattern = /^[^@]+@[^@]+\.[^@]+$/;
export function isEmail(s: string): boolean {
return emailPattern.test(s);
}
}
// Both declarations are merged — all exports accessible
const v: Validation.StringValidator = {
isAcceptable: (s) => Validation.isEmail(s),
};
console.log(v.isAcceptable('hi@example.com')); // trueMerging Namespaces with Functions
A classic JavaScript pattern is attaching properties directly to a function object. TypeScript models this with a function declaration merged with a namespace of the same name.
function buildLabel(name: string): string {
return `[${buildLabel.prefix}] ${name}`;
}
namespace buildLabel {
export let prefix = 'LABEL';
export function withVersion(name: string, version: string): string {
return `[${prefix}] ${name} v${version}`;
}
}
console.log(buildLabel('widget')); // [LABEL] widget
console.log(buildLabel.withVersion('widget', '2.0')); // [LABEL] widget v2.0
buildLabel.prefix = 'TAG';
console.log(buildLabel('card')); // [TAG] cardexpress() is callable as a function, and express.Router, express.json() etc. are properties attached to the same object.Merging Namespaces with Classes
Merging a namespace with a class adds static members and inner types to that class without modifying its original definition. This is ideal for extending classes from external libraries.
class Album {
label: Album.AlbumLabel;
constructor(
public title: string,
labelName: string,
) {
this.label = { name: labelName };
}
}
namespace Album {
export interface AlbumLabel {
name: string;
}
export function create(title: string, label = 'Unknown'): Album {
return new Album(title, label);
}
export const DEFAULT_LABEL = 'Independent';
}
const a = Album.create('Thriller', 'Epic Records');
console.log(a.label.name); // Epic Records
console.log(Album.DEFAULT_LABEL); // IndependentMerging Namespaces with Enums
Merging a namespace with an enum is the standard way to attach helper methods to an enum type — something the enum syntax itself does not support.
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
namespace Direction {
export function opposite(d: Direction): Direction {
const map: Record<Direction, Direction> = {
[Direction.Up]: Direction.Down,
[Direction.Down]: Direction.Up,
[Direction.Left]: Direction.Right,
[Direction.Right]: Direction.Left,
};
return map[d];
}
export function isVertical(d: Direction): boolean {
return d === Direction.Up || d === Direction.Down;
}
export function all(): Direction[] {
return [Direction.Up, Direction.Down, Direction.Left, Direction.Right];
}
}
console.log(Direction.opposite(Direction.Up)); // DOWN
console.log(Direction.isVertical(Direction.Left)); // false
console.log(Direction.all()); // ['UP','DOWN','LEFT','RIGHT']Module Augmentation
Module augmentation uses declaration merging to extend types exported from another module — including third-party packages — without editing their source files. The syntax is declare module 'module-name' { ... }.
A critical rule: your file must contain at least one top-level import or export statement, making it a module rather than a global script.
// types/express.d.ts
import 'express';
declare module 'express' {
interface Request {
currentUser?: {
id: string;
role: 'admin' | 'user';
permissions: string[];
};
}
}
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from './jwt';
export function authenticate(
req: Request,
res: Response,
next: NextFunction,
) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
req.currentUser = verifyToken(token); // fully typed!
next();
}
// src/routes/profile.ts
import { Request, Response } from 'express';
export function getProfile(req: Request, res: Response) {
if (!req.currentUser) {
return res.status(401).json({ error: 'Unauthorized' });
}
// TypeScript knows req.currentUser.id, .role, .permissions
res.json(req.currentUser);
}import 'express', the declare module block creates a brand-new ambient module rather than augmenting the existing one.Global Augmentation
Use declare global { ... } inside a module file to safely extend global interfaces like Window, Array, or String.
// src/types/globals.d.ts
export {}; // makes this file a module
declare global {
interface Window {
__APP_CONFIG__: {
apiUrl: string;
version: string;
featureFlags: Record<string, boolean>;
};
}
interface Array<T> {
last(): T | undefined;
first(): T | undefined;
}
}
// src/utils/array-extensions.ts
export {};
// Polyfill implementation (separate from the type declaration)
Array.prototype.last = function () {
return this[this.length - 1];
};
Array.prototype.first = function () {
return this[0];
};
// Usage — fully typed everywhere
const items = [1, 2, 3];
console.log(items.first()); // 1
console.log(items.last()); // 3
console.log(window.__APP_CONFIG__.apiUrl);Plugin Architecture Pattern
Declaration merging is the backbone of extensible plugin systems. A core library exposes an empty interface; plugins augment it to register their own options. The consuming application gets full type safety after importing any combination of plugins.
// packages/core/src/index.ts
export interface PluginOptions {
// empty — plugins add to this
}
export function createApp(opts: PluginOptions) {
console.log('App configured with:', opts);
}
// packages/plugin-auth/src/index.ts
import 'core';
declare module 'core' {
interface PluginOptions {
auth?: {
provider: 'jwt' | 'oauth2' | 'saml';
secret?: string;
issuer?: string;
};
}
}
export function setupAuth() { /* ... */ }
// packages/plugin-cache/src/index.ts
import 'core';
declare module 'core' {
interface PluginOptions {
cache?: {
ttl: number;
strategy: 'lru' | 'lfu' | 'fifo';
maxSize?: number;
};
}
}
export function setupCache() { /* ... */ }
// app/src/main.ts — after importing both plugins
import 'plugin-auth';
import 'plugin-cache';
import { createApp } from 'core';
// Full IntelliSense for all plugin options!
createApp({
auth: { provider: 'jwt', secret: 'my-secret-key' },
cache: { ttl: 300, strategy: 'lru', maxSize: 1000 },
});Disallowed Merges
Not everything can merge. Type aliases never merge with anything, and classes cannot merge with other classes. Attempting either is a compile error.
// ❌ ERROR: Duplicate identifier 'Point'
type Point = { x: number };
type Point = { y: number }; // Error!
// ❌ ERROR: Duplicate identifier 'Counter'
class Counter { count = 0 }
class Counter { reset() {} } // Error!
// ✅ Interfaces merge cleanly
interface Point { x: number }
interface Point { y: number } // OK — merged to { x: number; y: number }
// ✅ Use intersection type as a type-alias alternative
type Point3D = Point & { z: number };Summary
Interface declarations with the same name in the same scope merge into one combined interface.
Namespaces can merge with other namespaces, classes, functions, and enums.
Function overloads in later-declared interfaces are placed before those from earlier declarations.
Module augmentation uses declare module to extend types exported from imported packages.
Global augmentation uses declare global inside a module file to extend built-in globals.
Type aliases and classes cannot merge — use interfaces when merging is required.
Plugin architectures leverage declaration merging so each plugin augments a shared interface with its own options.