TypeScript declaration merging — augment types you do not own
← Back
April 4, 2026TypeScript7 min read

TypeScript declaration merging — augment types you do not own

Published April 4, 20267 min read

Every Express app has the same problem: you add a user to req.user in a middleware, then fight TypeScript for the rest of the file because Request does not know that property exists. Declaration merging is the TypeScript feature that solves this cleanly — and it works for any type you do not own, from Express to Jest's global namespace to your own plugin system.

Augmenting Express Request

The classic use case: adding typed properties to the Express request object from middleware:

typescript
// src/types/express.d.ts
import { User } from '../models/User';

declare global {
  namespace Express {
    interface Request {
      user?: User;
      correlationId: string;
      startTime: number;
    }
  }
}
typescript
// middleware/auth.ts
import { RequestHandler } from 'express';
import { verifyToken } from '../auth';

export const authMiddleware: RequestHandler = async (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  req.user = await verifyToken(token);  // TypeScript knows this is valid now
  next();
};

// routes/profile.ts
app.get('/profile', authMiddleware, (req, res) => {
  // req.user is typed as User | undefined — TypeScript enforces the null check
  if (!req.user) return res.status(401).end();
  res.json({ name: req.user.name });  // fully typed
});

How declaration merging works

TypeScript merges declarations with the same name from different files. Interfaces merge additively — properties from multiple declarations are combined. This is how the Express augmentation works: Express defines Request, your declaration adds to it, and TypeScript merges them.

typescript
// TypeScript merges these automatically
interface Config {
  apiUrl: string;
}

interface Config {
  timeout: number;
}

// Result: Config has both apiUrl and timeout
const config: Config = { apiUrl: 'https://api.example.com', timeout: 5000 };

Augmenting a third-party library's types

If a library's types are incomplete or wrong, augment them instead of using any:

typescript
// Some library has incomplete types
// import { LegacyChart } from 'old-charting-lib';
// LegacyChart is missing the `theme` property

// src/types/old-charting-lib.d.ts
declare module 'old-charting-lib' {
  interface LegacyChart {
    theme: 'light' | 'dark';
    setTheme(theme: 'light' | 'dark'): void;
  }
}

// Now usage is fully typed
import { LegacyChart } from 'old-charting-lib';
const chart = new LegacyChart();
chart.setTheme('dark');  // TypeScript accepts this

Augmenting the global namespace

Add typed properties to window, globalThis, or the global scope:

typescript
// src/types/global.d.ts
declare global {
  interface Window {
    analytics: {
      track: (event: string, properties?: Record<string, unknown>) => void;
      identify: (userId: string, traits?: Record<string, unknown>) => void;
    };
    __APP_CONFIG__: {
      apiUrl: string;
      featureFlags: Record<string, boolean>;
    };
  }
}

// Usage — fully typed
window.analytics.track('button_clicked', { buttonId: 'signup' });
const flags = window.__APP_CONFIG__.featureFlags;

Building a plugin-friendly type system

Declaration merging enables plugin architectures where plugins can register their own types:

typescript
// Core library defines an extensible interface
// core/types.ts
export interface PluginRegistry {
  // Empty — plugins will add to this
}

export function getPlugin<K extends keyof PluginRegistry>(
  name: K
): PluginRegistry[K] {
  return pluginStore[name] as PluginRegistry[K];
}

// Plugin A registers its type
// plugins/analytics/types.d.ts
declare module 'core/types' {
  interface PluginRegistry {
    analytics: {
      track: (event: string) => void;
      flush: () => Promise<void>;
    };
  }
}

// Plugin B registers its type
// plugins/auth/types.d.ts
declare module 'core/types' {
  interface PluginRegistry {
    auth: {
      getUser: () => User | null;
      logout: () => void;
    };
  }
}

// Consumer — fully typed, TypeScript knows about both plugins
const analytics = getPlugin('analytics');  // type: { track, flush }
const auth = getPlugin('auth');            // type: { getUser, logout }

Namespace merging for organising types

typescript
// file1.ts
namespace API {
  export interface UserResponse {
    id: string;
    name: string;
  }
}

// file2.ts — merges with above
namespace API {
  export interface OrderResponse {
    id: string;
    total: number;
  }
}

// Both are available
const user: API.UserResponse = { id: '1', name: 'Alice' };
const order: API.OrderResponse = { id: '2', total: 99.99 };

The rules of merging

  • Interfaces merge: Properties are combined. Property types must be compatible if the same name appears in both.
  • Classes do not merge: Duplicate class declarations are an error.
  • Functions merge to overloads: Later declarations are tried first.
  • Namespaces merge: Exported members are combined.
  • Modules augment, not replace: declare module 'x' adds to the module, it does not replace it.

Declaration merging is TypeScript's extension point. Every major library that supports TypeScript uses it to let consumers add type safety to their customisations. Understanding it makes you the engineer on your team who knows how to fix "TypeScript doesn't know about this property" cleanly instead of reaching for as any.

Share this
← All Posts7 min read