cheat sheet

Declaration Merging

Deep dive into TypeScript declaration merging — interface merging, namespace + class, namespace + function, module augmentation, global augmentation, and the Express Request pattern.

Declaration Merging — Interfaces, Namespaces, Modules, Globals

What it is

Declaration merging is TypeScript's mechanism for combining multiple separate declarations of the same name into a single composite declaration. The most common case is two interface blocks with identical names — TypeScript silently merges their members. The same merging extends to namespaces (which can combine with classes, functions, or enums to attach static-style members), to modules (so consumers can augment a third-party package's types), and to the global scope (so libraries can extend Window or process.env). Each merge form follows specific rules, and once you internalise them you can model patterns that are awkward or impossible in pure ES syntax.

The merge taxonomy

TypeScript merges declarations along a small grid of allowed pairings. The compiler does not warn about same-name collisions — it merges what it can and errors on what it cannot.

LeftRightAllowed?Result
interfaceinterfaceYesMembers union'd
namespacenamespaceYesMembers union'd
namespaceclassYesStatics added to class
namespacefunctionYesProperties added to function
namespaceenumYesProperties added to enum
interfaceclassNoError
type aliastype aliasNoError (Duplicate identifier)
type aliasinterfaceNoError
enumenumSame const-ness onlyMembers union'd

Type aliases (type X = …) never merge — that's the single biggest behavioural difference between type and interface. If you need merging, use interface.

typescript
// Right — interfaces merge
interface Box { width: number }
interface Box { height: number }
const b: Box = { width: 10, height: 20 };
typescript
// Wrong — type aliases don't merge
type Box = { width: number };
type Box = { height: number };   // Error: Duplicate identifier 'Box'

Output: (none — exits 0 on success)

Interface merging

The simplest form. Two interface blocks with the same name in the same scope are combined into one with all members of both. Order doesn't matter for property declarations.

typescript
interface User {
  id: string;
  name: string;
}

interface User {
  email: string;
  createdAt: Date;
}

// Effectively:
// interface User { id: string; name: string; email: string; createdAt: Date; }

const u: User = {
  id: 'u_1',
  name: 'Alice Dev',
  email: 'alice@example.com',
  createdAt: new Date(),
};

Output: (none — exits 0 on success)

Method overload merging

When the same method is declared in multiple interface blocks, the signatures stack — they form an overload set, not a union. Order matters: signatures in later declarations take precedence (appear first in the overload resolution order).

typescript
interface Logger {
  log(message: string): void;
}

interface Logger {
  log(message: string, level: 'info' | 'warn' | 'error'): void;
  log(message: string, meta: Record<string, unknown>): void;
}

declare const logger: Logger;
logger.log('hello');                          // matches first overload
logger.log('failed', 'error');                // matches second
logger.log('done', { requestId: 'abc' });     // matches third

Output: (none — exits 0 on success)

Conflicting properties

If two interface blocks declare the same property with different types, the compiler errors. There is no "later wins" rule for property types.

typescript
interface Box { width: number }
interface Box { width: string }   // Error TS2717: Subsequent property declarations must have the same type

The fix is to use extends or rename one of the properties:

typescript
interface BaseBox { width: number }
interface StringBox extends BaseBox { label: string }

Namespace + namespace merging

Two namespace blocks with the same name merge their members. Non-exported members are private to each block; exported members are visible to consumers.

typescript
namespace Animals {
  export class Zebra { stripes = true; }
  let zebraCount = 0;   // private to this block
}

namespace Animals {
  export class Tiger { stripes = true; }
  export interface Habitat {
    biome: 'savannah' | 'jungle' | 'arctic';
  }
}

const z = new Animals.Zebra();
const t = new Animals.Tiger();
const h: Animals.Habitat = { biome: 'jungle' };

Output: (none — exits 0 on success)

The pattern is mostly used in .d.ts files (for legacy global libraries) or to group types alongside a class — see the next section.

Namespace + class merging

The most popular merge pattern: attach "static" members to a class via a namespace of the same name. The namespace block becomes a place to declare additional types, constants, or helpers that are reached via the class name.

typescript
class Album {
  constructor(public title: string, public artist: string) {}
  describe(): string {
    return `${this.title} by ${this.artist}`;
  }
}

namespace Album {
  // Types reachable as Album.Format, Album.Track
  export type Format = 'vinyl' | 'cd' | 'digital';
  export interface Track {
    title: string;
    durationSec: number;
  }

  // Values reachable as Album.empty(), Album.FORMATS
  export const FORMATS: Format[] = ['vinyl', 'cd', 'digital'];
  export function empty(): Album {
    return new Album('', '');
  }
}

const a = new Album('Discovery', 'Daft Punk');
const fmt: Album.Format = 'vinyl';
const track: Album.Track = { title: 'One More Time', durationSec: 320 };
const blank = Album.empty();
console.log(Album.FORMATS);

Output:

text
[ 'vinyl', 'cd', 'digital' ]

The class is itself the constructor and the type; the namespace is a "companion" that exposes related types and helpers under the class name. This avoids exporting many sibling names from the module while keeping a clean API.

Namespace-+-class is the TypeScript replacement for the Java pattern of "static nested types" — types that conceptually belong to a class but aren't instance properties.

Namespace + function merging

A function value can have properties attached via a namespace of the same name. This models the JS idiom where functions are objects with their own state or sub-routines.

typescript
function tick(): void {
  tick.count++;
  console.log(`tick #${tick.count}`);
}

namespace tick {
  export let count = 0;
  export function reset(): void { tick.count = 0; }
}

tick();
tick();
tick();
console.log(`total: ${tick.count}`);
tick.reset();
console.log(`after reset: ${tick.count}`);

Output:

text
tick #1
tick #2
tick #3
total: 3
after reset: 0

This pattern is what jQuery's old $() shape modelled in types: $() is callable, and $.ajax, $.fn are properties. Modern code rarely needs it, but you'll meet it in legacy declaration files.

Namespace + enum merging

A namespace can add helper functions to an enum.

typescript
enum Status {
  Pending = 'pending',
  Active = 'active',
  Closed = 'closed',
}

namespace Status {
  export function isTerminal(s: Status): boolean {
    return s === Status.Closed;
  }
  export function fromString(input: string): Status | undefined {
    return Object.values(Status).includes(input as Status)
      ? (input as Status)
      : undefined;
  }
}

console.log(Status.isTerminal(Status.Closed));
console.log(Status.fromString('active'));
console.log(Status.fromString('unknown'));

Output:

text
true
active
undefined

The enum is both a value (the object) and a type (the union of members); the namespace adds value-side helpers without changing the type.

Module augmentation

The most important merge form in modern TypeScript. Module augmentation lets you add types or members to a third-party module from your own .d.ts file. The augmentation block looks like a declare module 'name' block, but the surrounding file must be a module (have at least one import or export), so TypeScript knows you're augmenting an existing module rather than declaring a new one.

typescript
// src/types/express.d.ts
import 'express';   // makes this a module + pulls in the original declarations

declare module 'express' {
  interface Request {
    user?: { id: string; email: string };
    requestId: string;
  }
}
typescript
// src/middleware.ts
import type { Request, Response, NextFunction } from 'express';

export function authenticate(req: Request, res: Response, next: NextFunction): void {
  req.user = { id: 'u_1', email: 'alice@example.com' };
  req.requestId = crypto.randomUUID();
  next();
}

Output: (none — exits 0 on success)

The Request interface inside declare module 'express' merges with Express's own Request interface. Every consumer of express now sees req.user and req.requestId as typed properties.

How module augmentation works under the hood

  1. Express's own type declarations contain interface Request { … }.
  2. Your declare module 'express' { interface Request { … } } adds members to the same interface name.
  3. Interface merging combines them across declaration files.
  4. The result is one composite Request interface available everywhere express is imported.

The trick is that interface merging is global within a module's declaration space. Your augmentation effectively reaches back into Express's published types and adds to them — without forking Express.

Augmenting global packages — e.g. next/server

The same pattern works for ES module packages. Augment NextRequest:

typescript
import 'next/server';

declare module 'next/server' {
  interface NextRequest {
    sessionId?: string;
  }
}

Output: (none — exits 0 on success)

Global augmentation

To add to the global scope (the Window, globalThis, NodeJS.ProcessEnv, JSX.IntrinsicElements), wrap declarations in declare global { … } inside a module file.

typescript
// src/types/globals.d.ts
export {};   // marks the file as a module

declare global {
  interface Window {
    __INITIAL_STATE__: { user: { id: string; name: string } | null };
  }

  namespace NodeJS {
    interface ProcessEnv {
      readonly API_URL: string;
      readonly NODE_ENV: 'development' | 'production' | 'test';
    }
  }
}
typescript
// src/app.ts
window.__INITIAL_STATE__ = { user: { id: 'u_1', name: 'Alice Dev' } };
console.log(process.env.API_URL);

Output: (none — exits 0 on success)

Without export {} (or any import/export), the file is treated as a script — and a declare global block becomes invalid in a script context. The empty export turns the file into a module while exporting nothing.

Across-file merging — the include rules

Two interface blocks merge only if the compiler sees them in the same declaration space. For global interfaces (Window, Array, JSX.IntrinsicElements), the merge is across the whole program. For module interfaces, the merge requires both declarations to target the same module specifier.

typescript
// src/a.ts
interface User { id: string }
typescript
// src/b.ts
interface User { name: string }

Are these merged? Nointerface User declared inside a module file is local to that file. Each file has its own User. To merge, both must be exported from the same module or both declared globally:

typescript
// src/types/user.d.ts
declare global {
  interface User { id: string; name: string }
}
export {};

Or, more typically, declared in the same file:

typescript
// src/types/user.ts
export interface User { id: string }
export interface User { name: string }   // merges with the line above

When NOT to use declaration merging

Declaration merging is a powerful tool with sharp edges. Reach for it sparingly:

  • Prefer composition over augmenting third-party types — adding properties to Express's Request is convenient, but it makes the type's shape depend on whether your .d.ts is loaded. If a teammate's editor doesn't pick up the augmentation, IntelliSense diverges from the runtime. Prefer wrapper functions that take a request and return a typed extension.
  • Don't merge interfaces across unrelated modules — if you find yourself merging interface User from three different files, the user model is the wrong abstraction. Hoist it to one canonical location.
  • Don't use namespace + function for new code — modern ES modules cover this case cleanly with module-level state. The pattern survives mostly in legacy .d.ts files.
  • Be careful with enum merging — adding new members across multiple declarations breaks exhaustiveness checks silently.

Common pitfalls

  1. Type alias vs interface for mergingtype X does not merge. If you need to merge, declare with interface. Mixing the two (one type, one interface) is a hard error.
  2. Forgetting import 'foo' in a module augmentation file — without it, TypeScript treats declare module 'foo' as a new module declaration that replaces (and conflicts with) Express's own types.
  3. Conflicting property typesinterface X { foo: number } and interface X { foo: string } error. The compiler doesn't pick a winner.
  4. Augmentation file not in include — declarations are only merged if the compiler loads the file. Confirm with tsc --listFiles | grep my-augmentation.d.ts.
  5. declare global without export {} — file is treated as a script, which can pollute or break depending on tsconfig.
  6. Merging on a class declaration directly — you cannot have two class Foo { … } blocks. Use a namespace next to the class to add members.
  7. Augmenting after import — runs too late — the declare module 'foo' must be statically reachable by every file that imports foo. Lazy/dynamic imports cannot pick up new types added at runtime.
  8. JSX intrinsic augmentation in React vs preactJSX.IntrinsicElements lives under different module namespaces (react, preact). Augment the right one or your custom element types won't apply.
  9. namespace + function order matters in .d.ts — the function declaration must come before the namespace. Otherwise TS emits Cannot find name 'tick' in the namespace body.
  10. Overload signature shadowing — adding an overload via merging can accidentally hide a more specific original. Inspect the resolved overload set with tsc --traceResolution or hover the call site.

Real-world recipes

Express Request with a typed user

The canonical Express middleware augmentation. Adds req.user everywhere Express is used.

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

declare module 'express-serve-static-core' {
  interface Request {
    user?: {
      id: string;
      email: string;
      roles: ('admin' | 'editor' | 'viewer')[];
    };
    requestId: string;
  }
}
typescript
// src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';

export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
  if (!req.user?.roles.includes('admin')) {
    res.status(403).send('forbidden');
    return;
  }
  next();
}
bash
tsc --noEmit

Output:

text
(no errors)

Augment express-serve-static-core, not express directly — that's the package that actually exports Request. Express re-exports it. Augmenting express works in most setups but breaks under some paths resolution quirks.

A Result class with static factories

The class is callable as new Result(...); the namespace adds factory helpers and related types under the same name.

typescript
class Result<T, E = Error> {
  private constructor(
    public readonly ok: boolean,
    public readonly value?: T,
    public readonly error?: E,
  ) {}

  static success<T>(value: T): Result<T, never> {
    return new Result(true, value);
  }

  static failure<E>(error: E): Result<never, E> {
    return new Result(false, undefined, error);
  }

  unwrap(): T {
    if (!this.ok) throw this.error;
    return this.value as T;
  }
}

namespace Result {
  export type Success<T> = Result<T, never>;
  export type Failure<E> = Result<never, E>;

  export function fromPromise<T>(p: Promise<T>): Promise<Result<T, Error>> {
    return p.then(
      (v) => Result.success(v),
      (e) => Result.failure(e instanceof Error ? e : new Error(String(e))),
    );
  }
}

const r1: Result.Success<number> = Result.success(42);
const r2 = await Result.fromPromise(fetch('/api/x').then((r) => r.json()));
console.log(r1.unwrap(), r2.ok);

Output:

text
42 true

Augmenting Vite's import.meta.env

Vite exposes import.meta.env as a generic Record<string, string>. Augment to narrow your project's known variables.

typescript
// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_SENTRY_DSN: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
typescript
const apiUrl = import.meta.env.VITE_API_URL;   // typed: string (not unknown)

Output: (none — exits 0 on success)

ImportMeta and ImportMetaEnv are global interfaces in Vite's declarations — merging them does not require declare module.

Custom matchers in Vitest / Jest

Test runners let you extend the expect API with custom matchers. Type them by augmenting the runner's interface.

typescript
// src/types/vitest.d.ts
import 'vitest';

interface CustomMatchers<R = unknown> {
  toBeWithinRange(min: number, max: number): R;
}

declare module 'vitest' {
  interface Assertion<T = any> extends CustomMatchers<T> {}
  interface AsymmetricMatchersContaining extends CustomMatchers {}
}
typescript
// src/test/setup.ts
import { expect } from 'vitest';

expect.extend({
  toBeWithinRange(received: number, min: number, max: number) {
    const pass = received >= min && received <= max;
    return { pass, message: () => `${received} ${pass ? 'is' : 'is not'} in [${min}, ${max}]` };
  },
});
typescript
// src/util.test.ts
import { describe, it, expect } from 'vitest';

describe('range', () => {
  it('matches', () => {
    expect(42).toBeWithinRange(1, 100);
  });
});

Output:

text
 ✓ src/util.test.ts (1)
   ✓ range (1)
     ✓ matches

 Test Files  1 passed (1)
      Tests  1 passed (1)

Extending Window with a third-party global

Loading Google Analytics or Stripe injects window.gtag / window.Stripe. Augment Window.

typescript
// src/types/window.d.ts
export {};

declare global {
  interface Window {
    gtag: (event: string, action: string, params?: Record<string, unknown>) => void;
    Stripe?: (publishableKey: string) => StripeInstance;
  }

  interface StripeInstance {
    redirectToCheckout(options: { sessionId: string }): Promise<{ error?: Error }>;
  }
}
typescript
// src/analytics.ts
window.gtag('event', 'page_view', { page_path: '/' });

Output: (none — exits 0 on success)

Adding a method to Array via global augmentation

Polyfilling Array.prototype.last() and informing the type system:

typescript
// src/polyfills/array-last.ts
Array.prototype.last = function <T>(this: T[]): T | undefined {
  return this[this.length - 1];
};

declare global {
  interface Array<T> {
    last(): T | undefined;
  }
}

export {};
typescript
import './polyfills/array-last';

console.log([1, 2, 3].last());
console.log([].last());

Output:

text
3
undefined

Adding methods to global prototypes is convenient and tempting — but a hazard in libraries. Two libraries that polyfill the same name with conflicting types will silently break consumers. Reserve global prototype augmentation for application code, not published packages.

Augmenting JSX intrinsic elements

You're using <my-button> as a custom element. The JSX checker doesn't know about it. Augment JSX.IntrinsicElements.

typescript
// src/types/jsx-elements.d.ts
import type { DetailedHTMLProps, HTMLAttributes } from 'react';

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'my-button': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
        variant?: 'primary' | 'secondary';
        loading?: boolean;
      };
    }
  }
}
tsx
function App() {
  return <my-button variant="primary" loading>Save</my-button>;
}

Output: (none — exits 0 on success)

The namespace JSX block merges with React's existing JSX namespace; the interface IntrinsicElements block merges with React's existing one. Two levels of merging, both transparent.

A logger with stacking overloads

Build up the logger API one signature at a time via interface merging.

typescript
// src/types/logger-core.d.ts
export interface Logger {
  log(message: string): void;
}
typescript
// src/types/logger-levels.d.ts
import './logger-core';

declare module './logger-core' {
  interface Logger {
    log(message: string, level: 'debug' | 'info' | 'warn' | 'error'): void;
  }
}
typescript
// src/types/logger-meta.d.ts
import './logger-core';

declare module './logger-core' {
  interface Logger {
    log(message: string, meta: Record<string, unknown>): void;
  }
}
typescript
// src/use-logger.ts
import type { Logger } from './types/logger-core';

declare const logger: Logger;

logger.log('starting');
logger.log('warning', 'warn');
logger.log('request', { requestId: 'abc', userId: 'u_1' });

Output: (none — exits 0 on success)

Each file adds a new overload to the existing Logger.log set. The complete overload list is the union of all declarations.