TypeScript Declaration Files: Writing .d.ts Files

Declaration files are TypeScript's mechanism for describing the shape of JavaScript code that exists elsewhere. When you use a JavaScript library in a TypeScript project, the compiler needs to know...

Key Insights

  • Declaration files (.d.ts) provide type information for JavaScript code without affecting runtime behavior, enabling TypeScript’s type checking and IntelliSense for untyped libraries
  • The declare keyword creates ambient declarations that describe shapes of existing JavaScript code rather than generating new code during compilation
  • Publishing libraries with accurate declaration files requires careful configuration of tsconfig.json and package.json, plus testing with tsc --noEmit to catch type errors

Introduction to Declaration Files

Declaration files are TypeScript’s mechanism for describing the shape of JavaScript code that exists elsewhere. When you use a JavaScript library in a TypeScript project, the compiler needs to know what types are available, what parameters functions accept, and what they return. This is where .d.ts files come in.

Unlike regular .ts files, declaration files don’t produce any JavaScript output during compilation. They exist purely to provide type information to the TypeScript compiler. This separation allows you to add strong typing to existing JavaScript codebases without rewriting them, and enables library authors to support both JavaScript and TypeScript consumers.

Consider this JavaScript library:

// math-utils.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

Without a declaration file, TypeScript treats the parameters as any and can’t provide helpful IntelliSense. With a declaration file:

// math-utils.d.ts
export function add(a: number, b: number): number;
export function multiply(a: number, b: number): number;

Now TypeScript knows the exact types, catches errors at compile time, and your editor provides autocomplete with parameter hints. This is the fundamental value proposition of declaration files.

Basic Declaration File Syntax

The declare keyword is central to writing declaration files. It tells TypeScript “this thing exists at runtime, here’s its type signature.” You’re not implementing functionality—you’re describing what already exists.

Here’s how to declare various JavaScript constructs:

// Function declarations
declare function parseConfig(path: string): Config;
declare function parseConfig(path: string, options: ParseOptions): Config;

// Interface declarations
interface Config {
  port: number;
  host: string;
  debug?: boolean;
}

interface ParseOptions {
  strict: boolean;
  encoding?: string;
}

// Variable declarations
declare const VERSION: string;
declare let currentUser: User | null;

// Class declarations
declare class Database {
  constructor(connectionString: string);
  query<T>(sql: string): Promise<T[]>;
  close(): void;
}

// Namespace declarations (for older libraries)
declare namespace Utils {
  function formatDate(date: Date): string;
  function parseDate(str: string): Date;
  
  namespace Validators {
    function isEmail(str: string): boolean;
  }
}

Notice that function bodies, class implementations, and variable initializations are absent. You’re only providing signatures. The actual implementation exists in the corresponding JavaScript file.

Declaring Module Types

Modern JavaScript uses ES modules, but you’ll encounter various module formats. Your declaration files need to match the module system used by the JavaScript code.

For ES modules, use standard export syntax:

// logger.d.ts
export interface LogLevel {
  level: 'info' | 'warn' | 'error';
  timestamp: number;
}

export function log(message: string, level?: LogLevel): void;
export function error(message: string): void;

export default class Logger {
  constructor(name: string);
  log(message: string): void;
}

For CommonJS modules that use module.exports, use the export = syntax:

// old-library.d.ts
declare function OldLibrary(config: OldLibrary.Config): OldLibrary.Instance;

declare namespace OldLibrary {
  interface Config {
    apiKey: string;
  }
  
  interface Instance {
    fetch(url: string): Promise<any>;
  }
}

export = OldLibrary;

Module augmentation lets you extend existing type definitions. This is invaluable when you need to add types for plugins or extend third-party libraries:

// express-extensions.d.ts
import 'express';

declare module 'express' {
  interface Request {
    user?: {
      id: string;
      email: string;
    };
  }
}

Now req.user is typed throughout your Express application without modifying the original @types/express package.

Advanced Declaration Patterns

Real-world libraries often have complex APIs requiring advanced TypeScript features in their declarations.

Generic declarations provide type safety for flexible APIs:

// Generic function declarations
declare function fetchData<T>(url: string): Promise<T>;
declare function createStore<S, A>(reducer: Reducer<S, A>): Store<S, A>;

// Generic class declarations
declare class Collection<T> {
  constructor(items: T[]);
  filter(predicate: (item: T) => boolean): Collection<T>;
  map<U>(transform: (item: T) => U): Collection<U>;
  toArray(): T[];
}

// Generic interface with constraints
interface Repository<T extends { id: string }> {
  find(id: string): Promise<T | null>;
  save(entity: T): Promise<void>;
  delete(id: string): Promise<boolean>;
}

Function overloads describe APIs that behave differently based on arguments:

// Different return types based on parameters
declare function createElement(tag: 'div'): HTMLDivElement;
declare function createElement(tag: 'span'): HTMLSpanElement;
declare function createElement(tag: string): HTMLElement;

// Optional parameters changing behavior
declare function request(url: string): Promise<string>;
declare function request(url: string, options: RequestOptions): Promise<Response>;

Utility types help create reusable type transformations:

// Helper types for common patterns
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

type EventMap<T> = {
  [K in keyof T]: (data: T[K]) => void;
};

// Using utility types in declarations
declare class EventEmitter<Events> {
  on<K extends keyof Events>(event: K, handler: Events[K]): void;
  emit<K extends keyof Events>(event: K, data: Parameters<Events[K]>[0]): void;
}

Working with Third-Party Libraries

The DefinitelyTyped repository hosts type declarations for thousands of JavaScript libraries. Before writing custom declarations, check if types already exist:

npm install --save-dev @types/library-name

However, when working with internal libraries, newer packages without types, or when you need customizations, you’ll write local declarations.

Create a types directory in your project root and configure TypeScript to find it:

// tsconfig.json
{
  "compilerOptions": {
    "typeRoots": ["./node_modules/@types", "./types"]
  }
}

Then create a declaration file matching the package name:

// types/untyped-library/index.d.ts
declare module 'untyped-library' {
  export interface Options {
    timeout: number;
    retries: number;
  }
  
  export function initialize(options: Options): void;
  export function execute(command: string): Promise<string>;
}

To extend existing @types packages, use module augmentation in your project:

// types/custom-extensions.d.ts
import { Request } from 'express';

declare module 'express-serve-static-core' {
  interface Request {
    requestId: string;
    startTime: number;
  }
}

Testing and Publishing Declaration Files

Type definitions need testing just like runtime code. Use tsc --noEmit to verify your declarations compile without errors:

// test-types.ts
import { add, multiply } from './math-utils';

// These should compile without errors
const sum: number = add(5, 3);
const product: number = multiply(4, 7);

// This should cause a type error
// const invalid: string = add(1, 2);

Configure your tsconfig.json to generate declaration files automatically:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

In your package.json, specify the types entry point:

{
  "name": "my-library",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": [
    "dist"
  ]
}

For libraries written in TypeScript, enable declarationMap to help users navigate to your source code when using “Go to Definition” in their editors.

Common Pitfalls and Best Practices

Avoid overly permissive types that defeat the purpose of type checking:

// Bad: Too broad
declare function process(data: any): any;

// Good: Specific types
declare function process(data: string | Buffer): ProcessedData;

Be careful with global declarations. Use module-scoped exports instead:

// Bad: Pollutes global namespace
declare global {
  function myUtility(x: number): string;
}

// Good: Module-scoped export
export function myUtility(x: number): string;

Only use declare global when you genuinely need to add to the global scope, such as for browser globals or Node.js globals.

Keep declaration files focused and avoid circular dependencies:

// Bad: Circular reference
// file-a.d.ts
import { TypeB } from './file-b';
export interface TypeA {
  b: TypeB;
}

// file-b.d.ts
import { TypeA } from './file-a';
export interface TypeB {
  a: TypeA;
}

// Good: Extract shared types
// types.d.ts
export interface TypeA {
  b: TypeB;
}
export interface TypeB {
  a: TypeA;
}

Prefer interfaces over type aliases for object shapes in declaration files—they provide better error messages and are more extensible through declaration merging.

Finally, document complex types with JSDoc comments. These appear in IntelliSense:

/**
 * Fetches user data from the API
 * @param userId - The unique identifier for the user
 * @param options - Optional fetch configuration
 * @returns A promise resolving to user data
 */
declare function fetchUser(
  userId: string,
  options?: FetchOptions
): Promise<User>;

Writing effective declaration files is essential for TypeScript adoption in JavaScript ecosystems. Master these patterns, and you’ll provide excellent developer experience for your libraries while maintaining the flexibility of JavaScript at runtime.

Liked this? There's more.

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