When no @types package exists for a dependency — or when you need to declare global variables, extend built-in objects, or type your project's internal modules — you write a .d.ts file. Declaration files are pure type information: they describe the shape of a module without any implementation. This lesson covers the patterns you'll use most often in a QA project.
The basics of a declaration file
A .d.ts file tells TypeScript what a module exports and what types those exports have. The file contains no runnable code — only type declarations.
// types/custom-reporter.d.ts
declare module 'custom-reporter' {
export interface ReportOptions {
outputPath: string;
format: 'html' | 'json' | 'junit';
includeScreenshots?: boolean;
title?: string;
}
export interface TestResult {
name: string;
status: 'passed' | 'failed' | 'skipped';
durationMs: number;
error?: string;
}
export function generateReport(
results: TestResult[],
options: ReportOptions
): Promise<void>;
export function clearOutputDir(path: string): void;
}With this file in place, importing custom-reporter gives you full autocomplete, parameter types, and return types — without modifying the library.
Telling TypeScript where to find your declarations
Add a typeRoots entry to tsconfig.json:
{
"compilerOptions": {
"typeRoots": ["./types", "./node_modules/@types"]
}
}The order matters — ./types is checked first, so your custom declarations take precedence over any @types conflict. Alternatively, include the types/ directory in your include pattern:
{
"include": ["src/**/*.ts", "types/**/*.d.ts"]
}Global variables and environment constants
CI pipelines, Playwright configs, and test setup files often read environment variables that are set globally. Typing them prevents process.env.BASE_URL! non-null assertions everywhere:
// types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
BASE_URL: string;
API_KEY: string;
TEST_ENV: 'local' | 'staging' | 'production';
HEADLESS?: string; // optional — may not be set
}
}Now process.env.BASE_URL is typed as string (not string | undefined), and process.env.NONEXISTENT is a compile error — you can only access keys declared in ProcessEnv.
Extending the Window object
End-to-end tests sometimes set properties on the browser's window object for test tooling:
// types/window.d.ts
declare global {
interface Window {
__testHelpers?: {
bypassAuth: () => void;
setFeatureFlag: (flag: string, value: boolean) => void;
};
// Analytics stub for test environments
analytics?: {
track: (event: string, properties?: Record<string, unknown>) => void;
};
}
}
export {}; // Required — makes this file a module, not a global scriptWithout export {}, TypeScript treats the file as a script rather than a module, and declare global behaves differently (or doesn't work at all). Always include export {} when using declare global.
Cypress custom command declarations
The most common .d.ts pattern in Cypress projects. Every custom command added with Cypress.Commands.add() must be declared in an interface augmentation to get type checking and autocomplete:
// types/cypress.d.ts
declare global {
namespace Cypress {
interface Chainable {
/**
* Log in via API and set the auth cookie.
* Faster than logging in through the UI.
*/
loginViaApi(email: string, password: string): Chainable<void>;
/**
* Seed a test user and return their ID.
* The user is cleaned up after the test via afterEach.
*/
seedUser(role: 'admin' | 'member' | 'guest'): Chainable<string>;
/**
* Wait for a specific API request to complete, identified by its alias.
*/
waitForApiRequest(alias: string): Chainable<void>;
}
}
}
export {};Once this file is in place, cy.loginViaApi() shows up in IDE autocomplete with its signature, and calling it with wrong argument types is a compile error.
- – declare module 'pkg'
- – Type only what you use
- – Minimal but precise
- – NodeJS.ProcessEnv
- – Window extensions
- – CI environment constants
- – Cypress.Chainable interface
- – Custom command signatures
- – Requires declare global
- declare module '*.json' –
- declare module '*.png' –
- Asset import support –
Typing JSON fixture imports
When you import JSON files in TypeScript (import users from './fixtures/users.json'), TypeScript can infer the exact type from the file contents with resolveJsonModule: true. But sometimes the fixture shape is too complex, and you want to declare the type explicitly:
// types/fixtures.d.ts
declare module '*.json' {
const value: unknown;
export default value;
}With unknown as the default, you're forced to narrow the type before using it — which is the safe option for fixture data that might change without warning.
Alternatively, if you control the fixture schema, use the inferred type and Zod for runtime validation:
import { z } from 'zod';
import rawUsers from './fixtures/users.json';
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member', 'guest']),
});
const users = rawUsers.map((u) => UserSchema.parse(u)); // throws if fixture is malformedWriting the minimal declaration pattern
The discipline for writing .d.ts files: look at your import statements first. Only declare what you import.
// Your code imports:
import { login, getCurrentUser } from 'legacy-auth-lib';
import type { AuthToken } from 'legacy-auth-lib';
// The minimal .d.ts covers exactly those three:
declare module 'legacy-auth-lib' {
export interface AuthToken {
value: string;
expiresAt: Date;
}
export function login(
email: string,
password: string
): Promise<AuthToken>;
export function getCurrentUser(): { id: string; email: string } | null;
// Everything else in legacy-auth-lib: not declared, not our problem
}This file takes ten minutes to write. It eliminates every type error for legacy-auth-lib imports in the entire project. Don't type the rest of the library — you may never need it, and you'd likely get it wrong.
⚠️ Common mistakes
- Forgetting
export {}at the bottom of files that usedeclare global. Without it, TypeScript treats the file as a script (ambient) rather than a module, and thedeclare globalaugmentation may conflict with or override other declarations unexpectedly. - Declaring the same module twice. If you have
declare module 'axios'in a custom.d.tsand@types/axiosis installed, you get a conflict. Remove the custom declaration and rely on@types/axios. - Putting executable code in a
.d.tsfile. Declaration files must contain only type declarations — noconst,let,functionimplementations, or any runnable code. TypeScript treats them as type-only. Anything you write there is a declaration, not an implementation.
🎯 Practice task
Write your first real .d.ts file.
- Identify an untyped module your migrated files import — either an internal
.jshelper you haven't converted or an npm package with no@types. - List every function, class, and type you import from that module.
- Create
types/<module-name>.d.tsand write a minimal declaration covering only those imports. - Add your
types/directory totypeRootsintsconfig.json. - Run
npm run type-check. Confirm the module errors are gone. - In VS Code, hover over an import from the newly declared module. Confirm the type signature appears in the tooltip.
- Stretch: if your project uses Cypress custom commands, write (or complete) a
types/cypress.d.tswith declarations for every command your tests call. Confirm thatcy.yourCommand()shows the correct signature in autocomplete.