Skip to content
Codeloom
TypeScript

Module Augmentation and Declaration Merging in TypeScript

Extend third-party modules, merge interfaces, and augment global types without modifying source code using declaration merging.

·6 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • How declaration merging works for interfaces and namespaces
  • Augment third-party modules to add custom types
  • Extend global types like Window and Process safely

Prerequisites

  • Basic TypeScript modules and imports
  • Understanding of interfaces and type declarations

TypeScript lets you extend existing type declarations without modifying the original source. This is called declaration merging when it happens within the same module, and module augmentation when you reach into another module’s type space. Both are essential tools for working with third-party libraries that need extra type information specific to your application.

Interface Merging Basics

When you declare the same interface name twice in the same scope, TypeScript merges them into a single interface.

interface User {
  id: string;
  name: string;
}

interface User {
  email: string;
  createdAt: Date;
}

// The merged interface has all four properties
const user: User = {
  id: '1',
  name: 'Alice',
  email: 'alice@example.com',
  createdAt: new Date(),
};

This only works with interface, not type. Type aliases cannot be declared twice. This is one of the key practical differences between the two.

Namespace Merging

Namespaces merge with classes, functions, and enums to add static properties.

function createValidator(schema: string) {
  return { schema, validate: (data: unknown) => true };
}

namespace createValidator {
  export function fromJSON(json: string) {
    return createValidator(JSON.parse(json).schema);
  }
}

// Both the function and the namespace member are available
createValidator('email');
createValidator.fromJSON('{"schema": "email"}');

This pattern is common in libraries that export a function with additional static methods attached to it.

Module Augmentation

To add types to a module you do not own, use declare module. This is module augmentation.

// augment-express.d.ts
import 'express';

declare module 'express' {
  interface Request {
    userId?: string;
    correlationId?: string;
  }
}

Now every Request object in Express has the optional userId and correlationId properties. Your middleware can set them, and your route handlers can read them with full type safety.

import express from 'express';

const app = express();

// Middleware sets the custom property
app.use((req, res, next) => {
  req.userId = req.headers['x-user-id'] as string;
  req.correlationId = crypto.randomUUID();
  next();
});

// Route handler reads it with type safety
app.get('/profile', (req, res) => {
  if (!req.userId) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  res.json({ userId: req.userId });
});

Augmenting Generic Modules

Some libraries export generic interfaces. You augment them the same way.

// Augment the Session interface from express-session
import 'express-session';

declare module 'express-session' {
  interface SessionData {
    cart: { productId: string; quantity: number }[];
    preferences: {
      theme: 'light' | 'dark';
      language: string;
    };
  }
}

Now req.session.cart and req.session.preferences are typed throughout your application.

Global Augmentation

To add properties to global types like Window, NodeJS.ProcessEnv, or globalThis, use declare global.

// global.d.ts
export {}; // This file must be a module

declare global {
  interface Window {
    analytics: {
      track(event: string, properties?: Record<string, unknown>): void;
      identify(userId: string): void;
    };
  }
}

The export {} at the top is critical. Without it, the file is a script (not a module), and declare global has no effect in script files because everything is already global.

// Now usable anywhere in the browser code
window.analytics.track('page_view', { path: '/home' });
window.analytics.identify('user-123');

Typing Environment Variables

A common pattern is augmenting NodeJS.ProcessEnv to get type-safe environment variable access.

// env.d.ts
export {};

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      DATABASE_URL: string;
      API_KEY: string;
      PORT?: string;
    }
  }
}
// Usage with full type safety
const dbUrl = process.env.DATABASE_URL; // string, not string | undefined
const port = process.env.PORT;          // string | undefined (optional)
const env = process.env.NODE_ENV;       // 'development' | 'production' | 'test'

Note that this is a compile-time guarantee only. At runtime, the variables might still be missing if the environment is misconfigured. Use a startup validation check alongside the type declaration.

Augmenting Class Libraries

You can augment libraries that export classes to add methods via interface merging on the prototype type.

// Augment Prisma client
import { PrismaClient } from '@prisma/client';

declare module '@prisma/client' {
  interface PrismaClient {
    $softDelete<T>(model: string, id: string): Promise<T>;
  }
}

// Implement the augmented method
const prisma = new PrismaClient();

(prisma as any).$softDelete = async function <T>(
  model: string,
  id: string
): Promise<T> {
  return (this as any)[model].update({
    where: { id },
    data: { deletedAt: new Date() },
  });
};

Creating Plugin Type Systems

Module augmentation enables plugin architectures where plugins register their types.

// core.ts - Base plugin system
export interface PluginRegistry {}

export type PluginName = keyof PluginRegistry;

export function getPlugin<K extends PluginName>(name: K): PluginRegistry[K] {
  // implementation
  return {} as PluginRegistry[K];
}

// auth-plugin.ts - A plugin augments the registry
declare module './core' {
  interface PluginRegistry {
    auth: {
      login(username: string, password: string): Promise<string>;
      logout(): void;
    };
  }
}

// analytics-plugin.ts - Another plugin
declare module './core' {
  interface PluginRegistry {
    analytics: {
      track(event: string): void;
      pageView(path: string): void;
    };
  }
}

// Consumer code gets full type safety
import { getPlugin } from './core';

const auth = getPlugin('auth');
auth.login('admin', 'secret'); // typed

const analytics = getPlugin('analytics');
analytics.track('signup'); // typed

Rules and Gotchas

You cannot add new top-level exports to a module through augmentation. You can only merge with existing declarations. If a library exports a type alias, you cannot merge with it since type aliases are not open for merging. You need the library to use an interface instead.

The augmentation file must be a module (it must have at least one import or export). Without that, the declare module block is treated as an ambient module declaration, which creates a new module rather than augmenting an existing one.

Order matters for resolution. Make sure your augmentation .d.ts files are included in your tsconfig.json via include or files.

// tsconfig.json
{
  "compilerOptions": { /* ... */ },
  "include": ["src/**/*", "types/**/*"]
}

Wrap Up

Module augmentation and declaration merging let you extend types you do not own. Use interface merging to grow interfaces incrementally, module augmentation to add custom properties to third-party libraries, and global augmentation to type Window, process.env, and other globals. These patterns are essential for typed middleware, plugin systems, and environment configuration in TypeScript projects.