TypeScriptDeclaration Merging

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.

TS
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,
};
Note
Both declarations must be visible in the same scope. Merging happens at the declaration site, not the usage site.

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.

TS
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 second
Namespace 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.

TS
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')); // true
Merging 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.

TS
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] card
Tip
This pattern mirrors how Express works: express() 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.

TS
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); // Independent
Merging 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.

TS
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.

TS
// 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);
}
Warning
Without the top-level 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.

TS
// 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.

TS
// 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 },
});
Success
This is the exact pattern used by Fastify plugins, Hapi plugins, and many other extensible Node.js frameworks — each plugin augments a shared interface, giving users complete type safety with zero coupling between plugins.
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.

TS
// ❌ 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.