TypeScript Declaration Merging and Module Augmentation
Extend Libraries and Build Flexible Type Systems

noteUpdated March 2026: Originally published in 2021; updated with contemporary patterns and ESLint/tooling integration.
Previous in the series: TypeScript Type Guards and Narrowing: Writing Type-Safe Conditionals
Also, for the fourth time as I update the banner images on this series, thank you Google Gemini.
Okay folks, scenario time.
You’re using a library that doesn’t have types. Or the types exist, but they’re missing one crucial property. Or you need to add something to the global scope.
Most developers reach for any. It’s fast. It works. Hell, I have done that too. But then your type safety evaporates instantly like water from indian roads on a hot 40 degree summer afternoon.
But there is a better way: declaration merging and module augmentation. These features let you extend types. Yours, third-party libraries, even the global scope without modifying the source.
You can think of it as TypeScript’s answer to “I need the types to be more flexible, but I don’t want to sacrifice safety.”
Well, color me thankful.
It’s also one of TypeScript’s best-kept secrets. Almost nobody uses it? Or maybe I haven’t seen enough TypeScript codebases.
Understanding Declaration Merging
Declaration merging is when TypeScript combines multiple definitions of the same name into a single definition. It’s powerful and, if misused, confusing.
But used correctly, it’s elegant. 🤌
Merging Namespaces
namespace Animals {
export class Dog {
bark() { console.log("Woof!"); }
}
}
namespace Animals {
export class Cat {
meow() { console.log("Meow!"); }
}
}
// Results in:
// namespace Animals {
// export class Dog { ... }
// export class Cat { ... }
// }
const dog = new Animals.Dog();
const cat = new Animals.Cat();Merging Interfaces
interface User {
name: string;
email: string;
}
interface User {
id: number;
}
interface User {
isActive?: boolean;
}
// All properties are merged:
// interface User {
// name: string;
// email: string;
// id: number;
// isActive?: boolean;
// }
const user: User = {
name: "Alice",
email: "alice@ex.com",
id: 1,
isActive: true
};Key Rules for Merging
- Interfaces merge easily (most flexible)
- Namespaces merge with interfaces
- Classes don’t merge (by design)
- Non-function members with the same name must have identical types (conflicts are errors, not overrides)
- Function members with the same name become overloads (later declarations get higher priority)
// Merging interfaces
interface Config {
timeout: number;
}
interface Config {
retries: number;
}
// Merging namespace with interface
namespace Settings {
export const timeout = 5000;
}
interface Settings {
debug: boolean;
}
// Can't merge classes
class MyClass {
method1() {}
}
class MyClass { // Error: Duplicate identifier
method2() {}
}Module Augmentation: Extending Library Types
This is where declaration merging becomes practical. You can extend third-party library types without modifying their source files.
Augmenting Existing Modules
// In your .d.ts file or somewhere in your project
declare module "express" {
interface Request {
user?: User;
authenticated: boolean;
}
}
// Now in your Express middleware:
import { Request } from "express";
app.use((req: Request, res, next) => {
req.user = getCurrentUser();
req.authenticated = !!req.user;
next();
});Real-World Example: Redux Store
// types/redux.d.ts
import type { User } from "./user";
declare module "@reduxjs/toolkit" {
interface DefaultRootState {
user: User | null;
loading: boolean;
error: string | null;
}
}
// Now TypeScript knows about your Redux state shape everywhere
import { useSelector } from "react-redux";
function MyComponent() {
const user = useSelector(state => state.user); // type is User | null
const loading = useSelector(state => state.loading); // type is boolean
}Augmenting Global Scope
// globals.d.ts
declare global {
interface Window {
myCustomApi: {
getData: () => Promise<any>;
processData: (data: any) => void;
};
}
}
// Now it's available everywhere without imports
window.myCustomApi.getData();Practical Patterns
Pattern 1: Extending Third-Party Components (React)
// types/react-custom.d.ts
import "react";
declare module "react" {
interface HTMLAttributes<T> {
dataTestId?: string;
customTheme?: "light" | "dark";
}
}
// Now use it in components:
function MyComponent() {
return <div dataTestId="my-div" customTheme="dark" />;
}Pattern 2: Adding Global Types to Window
// types/analytics.d.ts
declare global {
interface Window {
gtag?: typeof gtag;
analytics?: {
track: (event: string, data: Record<string, any>) => void;
identify: (userId: string, traits: Record<string, any>) => void;
};
}
}
export {}; // Make this a module
// Usage in scripts:
function trackEvent(name: string) {
window.analytics?.track(name, { timestamp: Date.now() });
}Pattern 3: Creating a Plugin System with Augmentation
// core/app.ts
export interface App {
name: string;
version: string;
}
// plugins/auth.ts
declare module "../core/app" {
interface App {
auth: {
login: (user: string, pass: string) => Promise<void>;
logout: () => void;
};
}
}
// plugins/database.ts
declare module "../core/app" {
interface App {
db: {
query: (sql: string) => Promise<any[]>;
insert: (table: string, data: any) => Promise<void>;
};
}
}
// main.ts
import { App } from "./core/app";
import "./plugins/auth";
import "./plugins/database";
const app: App = {
name: "MyApp",
version: "1.0.0",
auth: { login, logout }, // Merged by augmentation
db: { query, insert } // Merged by augmentation
};Advanced: Custom Type Extensions
bonusI used to just throw custom properties into Express Request without organizing them. Then I realized that I can separate each augmentation concern into its own .d.ts file. Auth goes in
auth.d.ts, logging goes inlogging.d.ts. It’s minutes of setup that saves you hours of debugging when you need to figure out “where did this property come from?”
Extending Express with Custom Middleware
// types/express.d.ts
import { User } from "../models/User";
declare global {
namespace Express {
interface Request {
user?: User;
correlationId: string;
startTime: number;
}
}
}
// middleware/auth.ts
import { Request, Response, NextFunction } from "express";
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
req.user = getCurrentUser();
next();
}
// routes/users.ts
router.get("/:id", authMiddleware, (req: Request, res: Response) => {
if (req.user) { // TypeScript knows this property exists
console.log(`User ${req.user.id} accessing resource`);
}
});Extending Fetch API
// types/fetch.d.ts
interface RequestInit {
timeout?: number;
retries?: number;
onProgress?: (loaded: number, total: number) => void;
}
declare global {
namespace globalThis {
function enhancedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response>;
}
}
// utils/fetch.ts
export async function enhancedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const timeout = init?.timeout ?? 5000;
const retries = init?.retries ?? 0;
// Implementation...
}Working with Declaration Files (.d.ts)
Creating Type-Only Packages
// types/my-library.d.ts
export interface Config {
apiKey: string;
apiSecret: string;
environment: "dev" | "staging" | "prod";
}
export function initialize(config: Config): void;
export function getStatus(): Promise<StatusResponse>;
interface StatusResponse {
isAlive: boolean;
uptime: number;
}Ambient Declarations
// types/vendor.d.ts
declare const VENDOR_API: {
init: (config: any) => void;
getData: () => Promise<any>;
};
declare namespace GL {
interface WebGLRenderingContext {
customExtension?: CustomGL;
}
}
interface CustomGL {
renderCustom: (data: any) => void;
}Common Pitfalls
Pitfall 1: Forgetting the Module Path
// Won't work - no module specified
declare global {
interface Request {
user?: User;
}
}
// Correct - specify the module
declare module "express" {
interface Request {
user?: User;
}
}Pitfall 2: Conflicting Merge Types
// Can't merge interface with type alias in same declaration
type User = { id: number };
interface User { // Error!
name: string;
}
// Use interface for both, or separate declarations
interface User {
id: number;
}
interface User {
name: string;
}Pitfall 3: Exported vs Global Declarations
Without at least one import or export, declare module "..." creates an ambient module declaration (fully defining the module’s types) instead of augmenting the existing module. Add export {} to make it a module file:
// Without import/export, this REPLACES the module's types entirely
declare module "express" {
interface Request {
user?: User;
}
}// With export {}, this AUGMENTS the existing module types
declare module "express" {
interface Request {
user?: User;
}
}
export {}; // Makes this file a module → augmentation, not replacementBest Practices
1. Use Separate Declaration Files
// types/
// ├── express.d.ts
// ├── window.d.ts
// ├── custom-libs.d.ts
// └── global.d.ts
// types/express.d.ts
declare module "express" {
// Express augmentations
}
// types/window.d.ts
declare global {
interface Window {
// Window augmentations
}
}2. Document and Comment
/**
* @augments express.Request
* Adds authenticated user to request object.
* Set by authMiddleware.
*/
declare module "express" {
interface Request {
/**
* The authenticated user, or undefined if not authenticated.
* Only available after authMiddleware is applied.
*/
user?: User;
}
}3. Use Triple-Slash Directives for Global Types
// types/globals.d.ts
/// <reference types="node" />
declare global {
interface ProcessEnv {
API_URL: string;
APP_ENV: "development" | "staging" | "production";
}
}
export {};4. Keep Augmentations Close to Usage
// middleware/auth.ts
import { Request } from "express";
// Augmentation happens here where it's used
declare module "express" {
interface Request {
user?: CurrentUser;
}
}
export function authMiddleware(req: Request, res, next) {
req.user = getCurrentUser();
next();
}Making it Strict
If you’re augmenting third-party types, always use strict mode. This ensures your augmentations are type-safe and won’t introduce subtle bugs. See Part 2 of this series for a complete guide to setting up strict mode in your tsconfig.json.
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"typeRoots": ["./node_modules/@types", "./types"],
"skipLibCheck": false // Don't skip checking @types
}
}With skipLibCheck: false, TypeScript validates all your type definitions aggressively.
Real-World: Full Example
// types/express-custom.d.ts
import type { Document } from "mongoose";
import type { Request } from "express";
interface AuthUser extends Document {
_id: string;
email: string;
roles: string[];
}
declare global {
namespace Express {
interface Request {
user?: AuthUser;
correlationId: string;
requestStartTime: number;
}
}
}
// middleware/auth.ts
import { Request, Response, NextFunction } from "express";
export async function authenticateUser(
req: Request,
res: Response,
next: NextFunction
) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
req.user = undefined;
return next();
}
try {
const payload = verifyToken(token);
req.user = await User.findById(payload.id);
next();
} catch (err) {
res.status(401).json({ error: "Unauthorized" });
}
}
// routes/protected.ts
router.post("/admin", authenticateUser, (req: Request, res: Response) => {
if (!req.user || !req.user.roles.includes("admin")) {
return res.status(403).json({ error: "Forbidden" });
}
// req.user is fully typed here
console.log(`Admin action by ${req.user.email}`);
});Wrapping up
- Declaration merging combines multiple definitions of the same name
- Module augmentation extends library types without modifying source code
- Use separate
.d.tsfiles for organization and clarity - Global augmentation is powerful but use sparingly. Prefer explicit imports
- Document augmentations so team members understand the extensions
- Keep augmentations close to where they’re used (usually in middleware/setup files)
Declaration merging and module augmentation bridge the gap between TypeScript’s type system and the real world of third-party libraries and global functionality. Used properly, they let you extend what exists without breaking what works.
As I wrap up this series, I hope you now have a comprehensive foundation for writing type-safe, maintainable TypeScript code. From utility types and strict mode through type narrowing and advanced type extensions.
These patterns compound. Each one makes the others more powerful. Each one catches more bugs.
The best part? Once you stop thinking about types as constraints and start thinking about them as documentation and guides, TypeScript becomes a collaborator instead of a gatekeeper. Combined with strict mode and ESLint, it makes it dramatically harder to ship undefined references. It makes it nearly impossible to forget a case. It catches most of the ways you’d accidentally break your own code.
That’s not restrictive. That’s freedom.
Previous parts of this series:




