⚡IntermediateModules & Declaration Files

Module Augmentation & Declaration Merging

Extend third-party types, merge interfaces across files, and augment existing modules without modifying their source code.

Overview

**Declaration merging** is TypeScript's ability to combine multiple declarations with the same name into a single definition. The most common case is **interface merging**: two interfaces with the same name in the same scope are automatically merged into one. **Module augmentation** extends this to modules: you can add new exports or extend existing types in an already-declared module (even a third-party `node_modules` package) by re-opening it with `declare module 'module-name' { ... }`. This is how many libraries let you add custom properties: Express augments `Request` to add a `user` property; Prisma augments its client types per schema; i18next augments resource types per locale. You never touch the library's source - you just open the module in your own file and add to it. The golden rule: you can **add** to existing declarations via merging, but you cannot **remove** or **replace** existing members.

Key Points

  • •Interface merging: two `interface Foo` declarations in the same scope are combined automatically
  • •Module augmentation: `declare module "existing-module" { ... }` adds to an already-declared module
  • •Must import from (or use) the module first - augmentation is additive, not replacement
  • •Class merging is limited - you cannot merge two class declarations; only class + interface
  • •Namespace merging: namespaces with the same name merge their members
  • •Common pattern: extending Express `Request` type with `user?: User`
  • •Augmenting built-in types: add methods to `Array<T>` via `interface Array<T> { ... }`

Examples

Interface merging across files

// config.ts
interface AppConfig {
  name: string;
  version: string;
}

// theme.ts  -  same interface name merges automatically
interface AppConfig {
  theme: 'light' | 'dark';
  primaryColor: string;
}

// auth.ts  -  another merge
interface AppConfig {
  auth: {
    provider: 'local' | 'oauth';
    timeout: number;
  };
}

// TypeScript sees ALL three declarations as one merged interface
const config: AppConfig = {
  name: 'MyApp',
  version: '1.0.0',
  theme: 'dark',
  primaryColor: '#3b82f6',
  auth: { provider: 'oauth', timeout: 3600 },
};

// This is how large codebases let different feature modules
// contribute to a shared type without a central definition file.

Augmenting Express Request (real-world pattern)

// types/express.d.ts

import 'express'; // Must import first to trigger augmentation (not replacement)

interface User {
  id: number;
  email: string;
  role: 'admin' | 'user';
}

declare module 'express-serve-static-core' {
  interface Request {
    user?: User;
    requestId: string;
  }
}

// ----- middleware.ts -----
import { Request, Response, NextFunction } from 'express';

export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  // req.user is now typed as User | undefined  no casting needed
  req.user = { id: 1, email: 'alice@example.com', role: 'admin' };
  req.requestId = crypto.randomUUID();
  next();
}

export function adminOnly(req: Request, res: Response, next: NextFunction) {
  if (req.user?.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
}

Augmenting built-in types and namespaces

// Add a custom method to all arrays
interface Array<T> {
  last(): T | undefined;
  sum(this: number[]): number;
}

Array.prototype.last = function () {
  return this[this.length - 1];
};

Array.prototype.sum = function (this: number[]) {
  return this.reduce((a, b) => a + b, 0);
};

const nums = [1, 2, 3, 4, 5];
console.log(nums.last()); // 5
console.log(nums.sum());  // 15

const words = ['hello', 'world'];
console.log(words.last()); // "world"

// Namespace merging  -  add a value to an enum namespace
enum Direction { Up, Down }

namespace Direction {
  export function isVertical(d: Direction): boolean {
    return d === Direction.Up || d === Direction.Down;
  }
}

console.log(Direction.isVertical(Direction.Up)); // true

Common Mistakes

  • Trying to remove or override existing members - merging is strictly additive; you cannot change an existing property's type by redeclaring it
  • Augmenting `express` directly instead of `express-serve-static-core` - Express types are split across packages; check which interface actually needs augmenting
  • Forgetting `import "the-module"` before augmenting - without the import, TypeScript treats the `declare module` as a replacement (ambient module), not an augmentation

Note

The difference between ambient module declaration (`declare module "foo" {}` without an import - replaces) and module augmentation (`import "foo"; declare module "foo" {}` with an import - adds) is subtle but critical. Always have an import statement in the same file to signal augmentation intent.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 3 exercises

Knowledge Check

Question 1 of 4

What happens when two interfaces have the same name in the same scope?