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.
| Left | Right | Allowed? | Result |
|---|---|---|---|
interface | interface | Yes | Members union'd |
namespace | namespace | Yes | Members union'd |
namespace | class | Yes | Statics added to class |
namespace | function | Yes | Properties added to function |
namespace | enum | Yes | Properties added to enum |
interface | class | No | Error |
type alias | type alias | No | Error (Duplicate identifier) |
type alias | interface | No | Error |
enum | enum | Same const-ness only | Members 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.
// Right — interfaces merge
interface Box { width: number }
interface Box { height: number }
const b: Box = { width: 10, height: 20 };
// 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.
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).
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.
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:
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.
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.
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:
[ '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.
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:
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.
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:
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.
// 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;
}
}
// 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
- Express's own type declarations contain
interface Request { … }. - Your
declare module 'express' { interface Request { … } }adds members to the same interface name. - Interface merging combines them across declaration files.
- The result is one composite
Requestinterface available everywhereexpressis 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:
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.
// 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';
}
}
}
// 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.
// src/a.ts
interface User { id: string }
// src/b.ts
interface User { name: string }
Are these merged? No — interface 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:
// src/types/user.d.ts
declare global {
interface User { id: string; name: string }
}
export {};
Or, more typically, declared in the same file:
// 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
Requestis convenient, but it makes the type's shape depend on whether your.d.tsis 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 Userfrom 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.tsfiles. - Be careful with
enummerging — adding new members across multiple declarations breaks exhaustiveness checks silently.
Common pitfalls
- Type alias vs interface for merging —
type Xdoes not merge. If you need to merge, declare withinterface. Mixing the two (onetype, oneinterface) is a hard error. - Forgetting
import 'foo'in a module augmentation file — without it, TypeScript treatsdeclare module 'foo'as a new module declaration that replaces (and conflicts with) Express's own types. - Conflicting property types —
interface X { foo: number }andinterface X { foo: string }error. The compiler doesn't pick a winner. - Augmentation file not in
include— declarations are only merged if the compiler loads the file. Confirm withtsc --listFiles | grep my-augmentation.d.ts. declare globalwithoutexport {}— file is treated as a script, which can pollute or break depending ontsconfig.- Merging on a
classdeclaration directly — you cannot have twoclass Foo { … }blocks. Use a namespace next to the class to add members. - Augmenting after import — runs too late — the
declare module 'foo'must be statically reachable by every file that importsfoo. Lazy/dynamic imports cannot pick up new types added at runtime. - JSX intrinsic augmentation in React vs preact —
JSX.IntrinsicElementslives under different module namespaces (react,preact). Augment the right one or your custom element types won't apply. namespace+functionorder matters in.d.ts— the function declaration must come before the namespace. Otherwise TS emitsCannot find name 'tick'in the namespace body.- Overload signature shadowing — adding an overload via merging can accidentally hide a more specific original. Inspect the resolved overload set with
tsc --traceResolutionor 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.
// 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;
}
}
// 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();
}
tsc --noEmit
Output:
(no errors)
Augment
express-serve-static-core, notexpressdirectly — that's the package that actually exportsRequest. Express re-exports it. Augmentingexpressworks in most setups but breaks under somepathsresolution 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.
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:
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.
// 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;
}
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.
// 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 {}
}
// 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}]` };
},
});
// src/util.test.ts
import { describe, it, expect } from 'vitest';
describe('range', () => {
it('matches', () => {
expect(42).toBeWithinRange(1, 100);
});
});
Output:
✓ 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.
// 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 }>;
}
}
// 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:
// 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 {};
import './polyfills/array-last';
console.log([1, 2, 3].last());
console.log([].last());
Output:
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.
// 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;
};
}
}
}
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.
// src/types/logger-core.d.ts
export interface Logger {
log(message: string): void;
}
// src/types/logger-levels.d.ts
import './logger-core';
declare module './logger-core' {
interface Logger {
log(message: string, level: 'debug' | 'info' | 'warn' | 'error'): void;
}
}
// src/types/logger-meta.d.ts
import './logger-core';
declare module './logger-core' {
interface Logger {
log(message: string, meta: Record<string, unknown>): void;
}
}
// 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.