TypeScript Declaration Merging: Interface and Namespace

TypeScript's declaration merging is a compiler feature that combines multiple declarations sharing the same name into a single definition. This isn't a runtime behavior—it's purely a type-level...

Key Insights

  • Declaration merging allows multiple declarations with the same name to combine into a single definition, enabling powerful patterns for extending types without modifying original source code
  • Interface-namespace merging lets you attach static properties and methods to interfaces, creating class-like structures that separate instance and static members
  • The primary practical use case is augmenting third-party library types, particularly for Express, DOM APIs, and plugin architectures where you need to extend existing interfaces

Understanding Declaration Merging

TypeScript’s declaration merging is a compiler feature that combines multiple declarations sharing the same name into a single definition. This isn’t a runtime behavior—it’s purely a type-level construct that gives you flexibility in how you organize and extend type definitions.

The TypeScript compiler merges declarations intelligently based on their kind. Interfaces merge with other interfaces, namespaces merge with other namespaces, and critically for this article, interfaces can merge with namespaces to create hybrid structures.

Here’s the simplest form of declaration merging with interfaces:

interface User {
  name: string;
}

interface User {
  email: string;
}

// Merged result: User has both name and email
const user: User = {
  name: "Alice",
  email: "alice@example.com"
};

The compiler treats these two User interface declarations as a single interface with both properties. This might seem redundant in a single file, but it becomes powerful when working across module boundaries or extending third-party types.

Interface Merging Rules

When TypeScript merges interfaces, it follows specific rules to ensure type safety. All properties from all declarations are combined, but conflicts are handled strictly.

For simple properties, types must be identical if declared multiple times:

interface Product {
  id: number;
  name: string;
}

interface Product {
  id: number; // OK: same type
  price: number;
}

// This would cause an error:
// interface Product {
//   id: string; // Error: subsequent property must have same type
// }

Method declarations follow different rules. Multiple method signatures merge as function overloads, with later declarations taking precedence:

interface Calculator {
  add(a: number, b: number): number;
}

interface Calculator {
  add(a: string, b: string): string;
  add(a: number[], b: number[]): number[];
}

const calc: Calculator = {
  add(a: any, b: any): any {
    if (typeof a === 'number') return a + b;
    if (typeof a === 'string') return a + b;
    if (Array.isArray(a)) return [...a, ...b];
  }
};

// All overloads are available
calc.add(1, 2);           // number
calc.add("hello", "world"); // string
calc.add([1], [2]);        // number[]

The overload signatures merge in the order they’re declared, which affects type resolution. TypeScript checks overloads from first to last, so order matters when you have overlapping signatures.

Merging Interfaces with Namespaces

The real power of declaration merging emerges when you combine interfaces with namespaces. This pattern lets you attach static properties and methods to an interface, mimicking how classes have both instance and static members.

interface Point {
  x: number;
  y: number;
}

namespace Point {
  export function origin(): Point {
    return { x: 0, y: 0 };
  }
  
  export function distance(p1: Point, p2: Point): number {
    return Math.sqrt(
      Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)
    );
  }
}

// Use as a type
const p1: Point = { x: 3, y: 4 };

// Use static methods
const p2 = Point.origin();
const dist = Point.distance(p1, p2);

This pattern is particularly useful when you want to group related utilities with a type without creating a full class. The interface defines the shape, while the namespace provides factory functions and utilities.

You must export members from the namespace for them to be accessible. Non-exported namespace members remain internal implementation details:

interface Config {
  apiKey: string;
  endpoint: string;
}

namespace Config {
  const DEFAULT_ENDPOINT = "https://api.example.com";
  
  export function create(apiKey: string): Config {
    return {
      apiKey,
      endpoint: DEFAULT_ENDPOINT
    };
  }
}

// Config.create is accessible
const config = Config.create("my-key");

// Config.DEFAULT_ENDPOINT is not accessible (not exported)

Practical Applications

The most common use case for declaration merging is augmenting third-party library types. When a library doesn’t provide complete type definitions or you need to add custom properties, declaration merging is your solution.

Here’s how you extend Express’s Request interface to add custom properties:

// types/express.d.ts
import 'express';

declare module 'express' {
  interface Request {
    user?: {
      id: string;
      email: string;
    };
    requestId: string;
  }
}

// Now in your middleware
import { Request, Response, NextFunction } from 'express';

function authMiddleware(req: Request, res: Response, next: NextFunction) {
  // TypeScript knows about req.user
  if (req.user) {
    console.log(`Authenticated user: ${req.user.email}`);
  }
  next();
}

The declare module syntax reopens the Express module and merges your interface declaration with the existing Request interface. This is type-safe module augmentation.

You can also extend global objects like Window:

// globals.d.ts
interface Window {
  analytics?: {
    track(event: string, properties?: Record<string, any>): void;
  };
}

// Now TypeScript recognizes window.analytics
window.analytics?.track('page_view', { path: window.location.pathname });

For plugin architectures, declaration merging enables extensible type systems:

interface Plugin {
  name: string;
  version: string;
}

namespace Plugin {
  const registry = new Map<string, Plugin>();
  
  export function register(plugin: Plugin): void {
    registry.set(plugin.name, plugin);
  }
  
  export function get(name: string): Plugin | undefined {
    return registry.get(name);
  }
}

// Plugins can extend the interface
interface Plugin {
  author?: string;
}

const myPlugin: Plugin = {
  name: "my-plugin",
  version: "1.0.0",
  author: "Developer"
};

Plugin.register(myPlugin);

Advanced Patterns and Pitfalls

Declaration order matters when merging. For interfaces, later declarations can reference earlier ones, but you can’t reference types that haven’t been declared yet:

interface Node {
  value: number;
  next?: Node; // OK: Node is already declared
}

interface Node {
  prev?: Node; // OK: references the merged Node
}

Generic interfaces can merge, but their generic parameters must match exactly:

interface Container<T> {
  value: T;
}

interface Container<T> {
  getValue(): T;
}

// This works
const stringContainer: Container<string> = {
  value: "hello",
  getValue() { return this.value; }
};

// This would error - type parameters must match:
// interface Container<U> {  // Error!
//   setValue(val: U): void;
// }

Be cautious with namespace-interface merging when using modules. The namespace must be in the same file or properly imported:

// point.ts
export interface Point {
  x: number;
  y: number;
}

export namespace Point {
  export function origin(): Point {
    return { x: 0, y: 0 };
  }
}

// consumer.ts
import { Point } from './point';

// Both interface and namespace are available
const p: Point = Point.origin();

Avoid declaration merging when you actually need distinct types. If two concepts happen to share a name but represent different things, rename one of them. Declaration merging is for extending a single concept, not for type reuse.

When to Use Declaration Merging

Use declaration merging when you need to:

  1. Extend third-party types without forking the library or using wrapper types
  2. Add static utilities to interfaces without creating full classes
  3. Incrementally define types across module boundaries in large codebases

Avoid declaration merging when:

  1. You control the source - just modify the original definition
  2. Types are conceptually different - use distinct names instead
  3. A class would be clearer - classes already support instance and static members naturally

For simple cases, prefer composition over merging. If you’re adding a single property to an object type, intersection types are often clearer:

type RequestWithUser = Request & { user: User };

Declaration merging shines when you need true augmentation—extending types you don’t control or creating extensible plugin systems where multiple modules contribute to a single type definition. Use it deliberately, document it clearly, and your codebase will benefit from the flexibility without sacrificing type safety.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.