Module Augmentation for Extending Library Types

8 min read

Module augmentation is TypeScript's mechanism for adding to an existing library's types without modifying the library itself. You've already seen it in action — Cypress custom commands use it. This lesson explains how it works, when to use it versus a plain .d.ts declaration, and the patterns that appear most often in QA projects.

Augmentation vs declaration — the key difference

The previous lesson covered declare module 'package-name' — writing types for a module that has none. Module augmentation is different: you're extending the types of a module that already has types.

// declare module — creates types for an untyped module (no prior types)
declare module 'untyped-lib' {
  export function doSomething(): void;
}
 
// module augmentation — adds to an already-typed module (imports first)
import 'typed-lib';
declare module 'typed-lib' {
  interface ExistingInterface {
    newMethod(): void; // added to the existing interface
  }
}

The import 'typed-lib' line before the declare module is what makes it augmentation rather than replacement. Without the import, TypeScript treats the declare module as a full declaration and discards the library's own types. With it, TypeScript merges your additions into the existing type definitions.

Cypress custom commands — the canonical example

Every custom Cypress command is an augmentation of Cypress.Chainable. The pattern you should use in every Cypress TypeScript project:

// cypress/support/commands.ts
 
// Step 1: Implement the command
Cypress.Commands.add('loginViaApi', (email: string, password: string): void => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password },
  }).then((response) => {
    cy.setCookie('auth_token', response.body.token as string);
  });
});
 
Cypress.Commands.add('seedUser', (role: 'admin' | 'member' | 'guest'): Cypress.Chainable<string> => {
  return cy
    .request('POST', '/api/test/seed/user', { role })
    .its('body.id');
});
// cypress/support/index.d.ts (or types/cypress.d.ts)
 
// Step 2: Declare the types
declare global {
  namespace Cypress {
    interface Chainable {
      loginViaApi(email: string, password: string): Chainable<void>;
      seedUser(role: 'admin' | 'member' | 'guest'): Chainable<string>;
    }
  }
}
 
export {};

Keep the implementation (Step 1) and the type declaration (Step 2) in separate files. The implementation file has runtime code; the declaration file has only types. This separation makes both easier to find and review.

Playwright custom fixtures — typed via test.extend

Playwright's fixture mechanism is already TypeScript-native. You extend the base test with a generic:

// tests/fixtures.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ApiClient } from '../helpers/api-client';
 
type QAFixtures = {
  loginPage: LoginPage;
  apiClient: ApiClient;
  authenticatedPage: Page;
};
 
export const test = base.extend<QAFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
 
  apiClient: async ({}, use) => {
    const client = new ApiClient(process.env.API_BASE_URL);
    await use(client);
    await client.cleanup();
  },
 
  authenticatedPage: async ({ page, apiClient }, use) => {
    const token = await apiClient.getAuthToken();
    await page.context().addCookies([{ name: 'auth_token', value: token, url: process.env.BASE_URL }]);
    await use(page);
  },
});

Tests that import test from this file get all three fixtures with full TypeScript types — no separate .d.ts augmentation needed:

import { test } from '../fixtures';
 
test('checkout with authenticated user', async ({ loginPage, apiClient, authenticatedPage }) => {
  // loginPage: LoginPage — typed
  // apiClient: ApiClient — typed
  // authenticatedPage: Page — typed
});

Adding methods to a third-party class

When a library's type definition is missing a method that exists at runtime (due to a version mismatch or an error in @types), augment the interface:

// types/supertest-extension.d.ts
import 'supertest';
 
declare module 'supertest' {
  interface Test {
    // Added in supertest v6.3 — @types/supertest lags behind
    withCredentials(credentials: boolean): this;
  }
}

After adding this, request(app).get('/').withCredentials(true) compiles without error.

declare module vs module augmentation — when to use each

declare module 'pkg'

  • Package has NO existing types

  • No import before declare module

  • Replaces the module's type signature

  • Used for: untyped npm packages, legacy internal modules

  • Risk: accidentally discards bundled types

import + declare module 'pkg'

  • Package HAS existing types

  • import 'pkg' before declare module

  • Merges additions into existing types

  • Used for: Cypress commands, Express request, missing methods

  • Safe: existing types stay intact

Augmenting Express Request for API test helpers

When writing Node.js API tests with Express middleware that adds properties to the request object, augment Express.Request:

// types/express.d.ts
import 'express';
 
declare module 'express' {
  interface Request {
    testUser?: {
      id: string;
      email: string;
      role: 'admin' | 'member';
    };
    correlationId?: string;
  }
}
 
export {};

Now req.testUser is available in middleware and route handlers without casting. TypeScript knows the shape.

When augmentation goes wrong

Module augmentation only works if TypeScript can find and load the original module. Common failure modes:

Missing import: forgetting import 'pkg' before declare module 'pkg' replaces rather than merges.

Wrong module specifier: declare module 'lodash' augments the top-level package, but declare module 'lodash/merge' augments the sub-path. They're different module identifiers — the wrong one silently fails to merge.

Augmenting in a script file: if the file has no imports or exports at all, TypeScript treats it as an ambient script, not a module. Augmentations in ambient scripts behave differently. Always add export {} to any .d.ts file that uses declare global or relies on module augmentation.

⚠️ Common mistakes

  • Using augmentation to redefine existing types. You can add new properties to an interface — you cannot change the type of an existing property. interface Chainable { click: (options: CustomOptions) => Chainable<void> } will conflict with Cypress's existing click signature and produce confusing errors. Augmentation is for additions only.
  • Putting the augmentation in the same file as the implementation. When Cypress processes commands.ts at runtime, it doesn't process type-only declarations. Keeping types in a separate .d.ts file prevents confusion about what's runtime code and what's type information.
  • Augmenting @types packages without checking the installed version. If you augment @types/node@18 to add a method from Node.js 20, the augmentation works — but it's a false signal that the method is available in your actual runtime. Always verify the runtime version supports what you're adding.

🎯 Practice task

Add a typed custom command to your Cypress project, or a typed custom fixture to your Playwright project.

For Cypress:

  1. Pick one custom command from cypress/support/commands.js that hasn't been typed yet.
  2. Rename commands.js to commands.ts and add types to the implementation.
  3. Create or update types/cypress.d.ts with the Chainable interface augmentation for that command.
  4. In VS Code, open a test file and type cy.yourCommandName(. Confirm the signature appears in the autocomplete tooltip.
  5. Try calling the command with the wrong argument type. Confirm TypeScript flags the error.

For Playwright:

  1. Create tests/fixtures.ts with a test.extend call that wraps at least one page object.
  2. Update one test file to import test from fixtures.ts instead of @playwright/test.
  3. Confirm the fixture parameter is fully typed in the test function body.

Stretch: find a third-party library in your project where the @types definition is missing a method you actually use. Write a module augmentation that adds the missing method's signature. Run npm run type-check to confirm the error is resolved without suppression.

// tip to track lessons you complete and pick up where you left off across devices.