TypeScript Module Augmentation: Extending Types

When working with third-party libraries in TypeScript, you'll inevitably need to add custom properties or methods that the library doesn't know about. Maybe you're attaching user data to Express...

Key Insights

  • Module augmentation lets you extend third-party library types without modifying their source code, enabling type-safe additions to libraries like Express, Axios, or global objects
  • TypeScript’s declaration merging automatically combines your augmentations with existing types, making extended properties available throughout your codebase
  • Organize augmentations in a dedicated types/ directory with clear naming conventions to maintain type safety and prevent conflicts in team environments

Introduction to Module Augmentation

When working with third-party libraries in TypeScript, you’ll inevitably need to add custom properties or methods that the library doesn’t know about. Maybe you’re attaching user data to Express requests, adding custom configuration to Axios, or extending the Window object with analytics tracking. Without proper typing, these additions force you into unsafe type assertions or living with TypeScript errors.

Module augmentation solves this problem by letting you extend existing type definitions without forking the library’s code. Here’s the problem in action:

// Without augmentation - TypeScript error
import express from 'express';
const app = express();

app.use((req, res, next) => {
  req.userId = '12345'; // Error: Property 'userId' does not exist on type 'Request'
  next();
});

// Forced to use type assertion (unsafe)
app.get('/profile', (req, res) => {
  const userId = (req as any).userId; // Loses type safety
});

With module augmentation, you can make TypeScript understand your extensions:

// With augmentation - fully typed
declare global {
  namespace Express {
    interface Request {
      userId: string;
    }
  }
}

app.use((req, res, next) => {
  req.userId = '12345'; // No error, fully typed
  next();
});

app.get('/profile', (req, res) => {
  const userId = req.userId; // Type: string
});

Basic Module Augmentation Syntax

Module augmentation uses ambient declarations to extend existing modules. TypeScript’s declaration merging automatically combines your additions with the original types. The basic syntax uses declare module followed by the module name:

// types/express.d.ts
import 'express';

declare module 'express' {
  interface Request {
    userId: string;
    sessionData: {
      loginTime: Date;
      permissions: string[];
    };
  }
}

The import statement at the top is crucial—it tells TypeScript you’re augmenting an existing module, not creating a new one. Without it, you’d be declaring a new module that shadows the original.

Here’s a practical example extending Express with authentication data:

// types/express-augmentation.d.ts
import 'express-serve-static-core';

declare module 'express-serve-static-core' {
  interface Request {
    user?: {
      id: string;
      email: string;
      roles: string[];
    };
    requestId: string;
    startTime: number;
  }

  interface Response {
    sendSuccess<T>(data: T): void;
    sendError(message: string, code?: number): void;
  }
}

Now you can use these properties with full type safety:

// middleware/auth.ts
import { Request, Response, NextFunction } from 'express';

export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
  req.user = {
    id: '123',
    email: 'user@example.com',
    roles: ['admin']
  };
  req.requestId = crypto.randomUUID();
  req.startTime = Date.now();
  next();
};

// routes/profile.ts
app.get('/profile', (req, res) => {
  if (!req.user) {
    return res.sendError('Unauthorized', 401);
  }
  res.sendSuccess({ user: req.user }); // Fully typed
});

Augmenting Global Namespaces and Interfaces

Global augmentation extends built-in JavaScript objects or the global namespace. Use declare global to augment types available everywhere:

// types/global.d.ts
declare global {
  interface Window {
    analytics: {
      track: (event: string, properties?: Record<string, any>) => void;
      identify: (userId: string) => void;
    };
    config: {
      apiUrl: string;
      features: Record<string, boolean>;
    };
  }
}

export {};

The empty export {} is important—it makes the file a module, which is required for global augmentation to work properly.

You can also extend built-in types like Array:

// types/array-extensions.d.ts
declare global {
  interface Array<T> {
    groupBy<K extends string | number>(
      keyFn: (item: T) => K
    ): Record<K, T[]>;
    
    unique(): T[];
    
    asyncMap<U>(
      fn: (item: T) => Promise<U>
    ): Promise<U[]>;
  }
}

export {};

For Node.js applications, augment the NodeJS namespace:

// types/node.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
      DATABASE_URL: string;
      API_KEY: string;
      PORT: string;
    }

    interface Global {
      __DEV__: boolean;
      __TEST__: boolean;
    }
  }
}

export {};

Now environment variables are properly typed:

const dbUrl = process.env.DATABASE_URL; // Type: string
const port = parseInt(process.env.PORT); // No undefined check needed

Real-World Use Cases

Module augmentation shines in middleware-heavy architectures. Here’s a complete authentication example:

// types/express-auth.d.ts
import 'express';

interface AuthUser {
  id: string;
  email: string;
  roles: string[];
  tenantId: string;
}

declare module 'express-serve-static-core' {
  interface Request {
    user?: AuthUser;
    tenant?: {
      id: string;
      name: string;
      plan: 'free' | 'pro' | 'enterprise';
    };
  }
}

// middleware/auth.ts
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
};

export const requireRole = (role: string) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user?.roles.includes(role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
};

Extending Axios with custom configuration:

// types/axios.d.ts
import 'axios';

declare module 'axios' {
  export interface AxiosRequestConfig {
    retry?: {
      retries: number;
      retryDelay: number;
    };
    cache?: {
      ttl: number;
      key?: string;
    };
    auth?: {
      token: string;
      refreshToken?: string;
    };
  }
}

// api/client.ts
import axios from 'axios';

const client = axios.create({
  baseURL: 'https://api.example.com',
  retry: {
    retries: 3,
    retryDelay: 1000
  },
  cache: {
    ttl: 60000
  }
});

For ORMs like Prisma, you can add computed properties:

// types/prisma.d.ts
import { User, Post } from '@prisma/client';

declare module '@prisma/client' {
  interface User {
    fullName: string;
    isAdmin: boolean;
  }

  interface Post {
    readingTime: number;
    excerpt: string;
  }
}

// models/user.ts
export function enrichUser(user: User): User {
  return {
    ...user,
    fullName: `${user.firstName} ${user.lastName}`,
    isAdmin: user.roles.includes('admin')
  };
}

Advanced Patterns and Merging Strategies

TypeScript merges interface declarations automatically, but classes and namespaces have different rules. Interfaces merge additively:

// Original library
interface Config {
  timeout: number;
}

// Your augmentation
declare module 'some-library' {
  interface Config {
    retries: number;
    cache: boolean;
  }
}

// Merged result: { timeout: number; retries: number; cache: boolean }

When augmenting classes, you can only add type information, not runtime behavior:

// types/library.d.ts
import { SomeClass } from 'some-library';

declare module 'some-library' {
  interface SomeClass {
    customMethod(): string; // Type-only, must be added at runtime
  }
}

// You must implement it somewhere
SomeClass.prototype.customMethod = function() {
  return 'custom';
};

Namespace augmentation follows similar patterns:

// types/library.d.ts
import * as LibNamespace from 'some-library';

declare module 'some-library' {
  namespace LibNamespace {
    interface Options {
      customOption: boolean;
    }
    
    function customUtility(input: string): string;
  }
}

Best Practices and Pitfalls

Organize augmentation files in a dedicated types/ directory with clear naming:

src/
  types/
    express.d.ts
    axios.d.ts
    global.d.ts
    prisma.d.ts
  middleware/
  routes/

Ensure your tsconfig.json includes the types directory:

{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./src/types"]
  },
  "include": ["src/**/*"]
}

Common mistakes to avoid:

// ❌ Wrong: Missing import
declare module 'express' {
  interface Request {
    userId: string;
  }
}

// ✅ Correct: Import first
import 'express';
declare module 'express' {
  interface Request {
    userId: string;
  }
}

// ❌ Wrong: Conflicting types
declare module 'express' {
  interface Request {
    userId: number; // Conflicts if already string
  }
}

// ✅ Correct: Use optional or union types
declare module 'express' {
  interface Request {
    userId?: string | number;
  }
}

Don’t augment when you can use wrapper types:

// ❌ Overkill for local use
declare module 'axios' {
  interface AxiosResponse {
    parsedData: any;
  }
}

// ✅ Better: Create a wrapper type
type EnhancedResponse<T> = AxiosResponse<T> & {
  parsedData: T;
};

Conclusion

Module augmentation is essential for maintaining type safety when extending third-party libraries. Use it when you need to add properties to library types that will be used across your codebase. For local extensions or transformations, prefer wrapper types or utility types that don’t modify global declarations.

The key patterns to remember: use import before declare module for library augmentation, use declare global for global types, and organize augmentations in a dedicated types directory. When properly structured, module augmentation gives you the type safety of forking without the maintenance burden.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.