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 existingclicksignature and produce confusing errors. Augmentation is for additions only. - Putting the augmentation in the same file as the implementation. When Cypress processes
commands.tsat runtime, it doesn't process type-only declarations. Keeping types in a separate.d.tsfile prevents confusion about what's runtime code and what's type information. - Augmenting
@typespackages without checking the installed version. If you augment@types/node@18to 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:
- Pick one custom command from
cypress/support/commands.jsthat hasn't been typed yet. - Rename
commands.jstocommands.tsand add types to the implementation. - Create or update
types/cypress.d.tswith the Chainable interface augmentation for that command. - In VS Code, open a test file and type
cy.yourCommandName(. Confirm the signature appears in the autocomplete tooltip. - Try calling the command with the wrong argument type. Confirm TypeScript flags the error.
For Playwright:
- Create
tests/fixtures.tswith atest.extendcall that wraps at least one page object. - Update one test file to import
testfromfixtures.tsinstead of@playwright/test. - 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.