Writing Custom Type Declarations (.d.ts)

9 min read

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 script

Without 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.

.d.ts use cases
  • – 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 malformed

Writing 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 use declare global. Without it, TypeScript treats the file as a script (ambient) rather than a module, and the declare global augmentation may conflict with or override other declarations unexpectedly.
  • Declaring the same module twice. If you have declare module 'axios' in a custom .d.ts and @types/axios is installed, you get a conflict. Remove the custom declaration and rely on @types/axios.
  • Putting executable code in a .d.ts file. Declaration files must contain only type declarations — no const, let, function implementations, 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.

  1. Identify an untyped module your migrated files import — either an internal .js helper you haven't converted or an npm package with no @types.
  2. List every function, class, and type you import from that module.
  3. Create types/<module-name>.d.ts and write a minimal declaration covering only those imports.
  4. Add your types/ directory to typeRoots in tsconfig.json.
  5. Run npm run type-check. Confirm the module errors are gone.
  6. In VS Code, hover over an import from the newly declared module. Confirm the type signature appears in the tooltip.
  7. Stretch: if your project uses Cypress custom commands, write (or complete) a types/cypress.d.ts with declarations for every command your tests call. Confirm that cy.yourCommand() shows the correct signature in autocomplete.

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