Module Augmentation and Declaration Merging
Extending Types You Don't Own
You're using Express. You add authentication middleware that attaches user to the request object. But now TypeScript yells at you every time you access req.user — it's not in Express's types. You can't edit Express's source code. So what do you do?
This is exactly the problem module augmentation and declaration merging solve. Together, they let you teach TypeScript about your custom additions to third-party libraries — without touching their code.
Think of declaration merging as adding pages to someone else's book. The original book (the library's types) stays unchanged, but you insert new pages (your declarations) that seamlessly extend the content. When TypeScript reads the book, it sees all pages — original and yours — as one unified type. Module augmentation is choosing which book to add pages to.
Interface Declaration Merging
When you declare an interface with the same name in the same scope, TypeScript merges them:
// First declaration
interface User {
name: string;
email: string;
}
// Second declaration — merges with the first
interface User {
age: number;
role: "admin" | "user";
}
// Merged result:
// interface User {
// name: string;
// email: string;
// age: number;
// role: "admin" | "user";
// }
const user: User = {
name: "Alice",
email: "alice@test.com",
age: 30,
role: "admin",
}; // All four properties required
Merging Rules
// Properties with the same name must have identical types
interface Config {
port: number;
}
interface Config {
port: string; // ERROR: Subsequent property declarations must have the same type
}
// Function overloads merge — later declarations have higher priority
interface API {
fetch(url: string): Promise<Response>;
}
interface API {
fetch(url: string, options: RequestInit): Promise<Response>;
}
// Merged: both overloads available, second declaration's overloads checked first
Only interface supports declaration merging. type aliases with the same name cause a "Duplicate identifier" error:
interface Mergeable { a: string; }
interface Mergeable { b: number; } // OK — merged
type NotMergeable = { a: string; };
type NotMergeable = { b: number; }; // ERROR: Duplicate identifierThis is why library authors use interface for types they want consumers to extend.
Module Augmentation
This is where you'll spend most of your time in practice. Module augmentation adds new exports or extends existing types in a third-party module:
// Extending Express Request
import { Request } from "express";
declare module "express" {
interface Request {
user?: {
id: string;
email: string;
role: "admin" | "user";
};
sessionId?: string;
}
}
// Now in your middleware:
app.get("/profile", (req: Request, res) => {
console.log(req.user?.email); // OK — TypeScript knows about user
console.log(req.sessionId); // OK — added by augmentation
});
Augmenting React Types
// Extending JSX IntrinsicElements for custom elements
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"custom-button": React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary";
loading?: boolean;
},
HTMLButtonElement
>;
}
}
}
// Now valid JSX:
<custom-button variant="primary" loading>Click me</custom-button>
Augmenting Next.js Types
// types/next.d.ts
import "next";
declare module "next" {
interface NextApiRequest {
userId?: string;
permissions?: string[];
}
}
// Extending Next.js metadata
import "next/dist/lib/metadata/types/metadata-interface";
declare module "next/dist/lib/metadata/types/metadata-interface" {
interface Metadata {
customField?: string;
}
}
How module augmentation works under the hood
When TypeScript sees declare module "express", it:
- Finds the existing module declaration for "express" (from
@types/expressor the library's types) - Takes your augmentation declarations and merges them with the existing module
- Interface declarations merge (adding new properties)
- New exports are added to the module
Important rules:
- The augmentation file MUST be a module (have at least one import or export)
- You can only add new properties to existing interfaces, not change existing ones
- You can add new exports but can't modify existing export types
- The
declare module "name"must match the exact module specifier used in imports
// WRONG — not a module (no import/export)
declare module "express" {
interface Request { user: User; }
}
// CORRECT — import makes it a module
import "express";
declare module "express" {
interface Request { user: User; }
}Global Augmentation
For adding to global scope from a module file:
// Adding to globalThis
export {};
declare global {
interface Window {
analytics: {
track(event: string): void;
identify(userId: string): void;
};
}
// Add a global utility type
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Extend Array prototype (if you've added methods)
interface Array<T> {
last(): T | undefined;
compact(): NonNullable<T>[];
}
}
Production Scenario: Full-Stack Type Sharing
// types/api.d.ts — shared between frontend and backend
import "express";
// Augment Express with shared types
declare module "express" {
interface Request {
user?: AuthenticatedUser;
organizationId?: string;
requestId: string;
}
interface Response {
success<T>(data: T): void;
error(code: string, message: string, status?: number): void;
}
}
// Augment environment variables
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
API_SECRET: string;
REDIS_URL?: string;
PORT?: string;
}
}
}
export interface AuthenticatedUser {
id: string;
email: string;
role: "admin" | "editor" | "viewer";
organizationId: string;
}
// Now throughout the codebase:
// process.env.DATABASE_URL is string (not string | undefined)
// process.env.NODE_ENV is "development" | "production" | "test"
// req.user?.role is "admin" | "editor" | "viewer"
| What developers do | What they should do |
|---|---|
| Forgetting to make the augmentation file a module (missing import/export) Without imports/exports, declarations go to global scope instead of augmenting the target module | Always have at least import 'module-name' or export {} to make the file a module |
| Trying to change the type of an existing property via merging Declaration merging adds to interfaces, it doesn't override. Conflicting property types cause errors. | You can only ADD new properties — existing properties must keep their original types |
| Using module augmentation for types that should be local Augmentation is global — it affects the entire codebase. Overuse makes types hard to trace. | Only augment when you genuinely need to extend third-party types. Use local types for your own code. |
| Putting augmentation files outside the TypeScript include path TypeScript only processes files in its include path. Augmentation files outside it are silently ignored. | Ensure your types/ directory is in tsconfig.json's include array or referenced via typeRoots |
Challenge: Augment a UI Library
// You're using a UI library called "base-ui" that exports a Button component.
// The library's types:
//
// declare module "base-ui" {
// interface ButtonProps {
// variant: "primary" | "secondary";
// size: "sm" | "md" | "lg";
// children: React.ReactNode;
// }
// export function Button(props: ButtonProps): JSX.Element;
// }
//
// Your team needs to:
// 1. Add a "ghost" and "danger" variant to ButtonProps
// 2. Add an "xl" size
// 3. Add optional "loading" and "icon" props
// 4. Type process.env.UI_THEME as "light" | "dark"
//
// Write the augmentation file:
// types/base-ui.d.ts
import "base-ui";
declare module "base-ui" {
interface ButtonProps {
variant: "primary" | "secondary" | "ghost" | "danger";
size: "sm" | "md" | "lg" | "xl";
loading?: boolean;
icon?: React.ReactNode;
}
}
declare global {
namespace NodeJS {
interface ProcessEnv {
UI_THEME: "light" | "dark";
}
}
}
export {};
Wait — there's a subtlety. You can't change the type of existing properties (variant, size) via declaration merging. The variant and size properties already exist with their original types.
The correct approach for extending string unions is to have the library use a pattern that's augmentation-friendly:
// If the library designed for extensibility:
// Original: interface ButtonVariants { primary: true; secondary: true; }
// type Variant = keyof ButtonVariants;
// Then you'd augment: interface ButtonVariants { ghost: true; danger: true; }
// If the library uses string literals, you may need to use
// type assertion or wrapper components instead of augmentation.
This highlights an important lesson: not all libraries are designed to be augmented. Interface merging adds properties — it can't change existing property types.
- 1Interface declarations with the same name merge automatically — type aliases cannot merge
- 2Module augmentation requires the file to be a module (import/export) — otherwise declarations go to global scope
- 3declare module 'x' augments existing module 'x' — from a module file. From a script file, it creates an ambient module.
- 4You can add new properties via merging but cannot change existing property types
- 5Library authors should use interface (not type) for types they want consumers to extend