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 interfacesinterface Config { timeout: number;}
interface Config { retries: number;}
// Merging namespace with interfacenamespace Settings { export const timeout = 5000;}
interface Settings { debug: boolean;}
// Can't merge classesclass 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 projectdeclare 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
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 everywhereimport { 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
declare global { interface Window { myCustomApi: { getData: () => Promise<any>; processData: (data: any) => void; }; }}
// Now it's available everywhere without importswindow.myCustomApi.getData();Practical Patterns
Pattern 1: Extending Third-Party Components (React)
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
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
export interface App { name: string; version: string;}
// plugins/auth.tsdeclare module "../core/app" { interface App { auth: { login: (user: string, pass: string) => Promise<void>; logout: () => void; }; }}
// plugins/database.tsdeclare module "../core/app" { interface App { db: { query: (sql: string) => Promise<any[]>; insert: (table: string, data: any) => Promise<void>; }; }}
// main.tsimport { 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
import { User } from "../models/User";
declare global { namespace Express { interface Request { user?: User; correlationId: string; startTime: number; } }}
// middleware/auth.tsimport { Request, Response, NextFunction } from "express";
export function authMiddleware(req: Request, res: Response, next: NextFunction) { req.user = getCurrentUser(); next();}
// routes/users.tsrouter.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
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.tsexport 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
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
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 specifieddeclare global { interface Request { user?: User; }}
// Correct - specify the moduledeclare module "express" { interface Request { user?: User; }}Pitfall 2: Conflicting Merge Types
// Can't merge interface with type alias in same declarationtype User = { id: number };interface User { // Error! name: string;}
// Use interface for both, or separate declarationsinterface 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 entirelydeclare module "express" { interface Request { user?: User; }}// With export {}, this AUGMENTS the existing module typesdeclare 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.tsdeclare module "express" { // Express augmentations}
// types/window.d.tsdeclare 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
/// <reference types="node" />
declare global { interface ProcessEnv { API_URL: string; APP_ENV: "development" | "staging" | "production"; }}
export {};4. Keep Augmentations Close to Usage
import { Request } from "express";
// Augmentation happens here where it's useddeclare 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
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.tsimport { Request, Response, NextFunction } from "express";
export async function authenticateUser( req: Request, res: Response, next: NextFunction) {9 collapsed lines
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();4 collapsed lines
} catch (err) { res.status(401).json({ error: "Unauthorized" }); }}
// routes/protected.tsrouter.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:





