Skip to content
Home
TypeScript Declaration Files: Writing .d.ts for Libraries

TypeScript Declaration Files: Writing .d.ts for Libraries

TypeScript TypeScript 8 min read 1495 words Beginner ExcellentWiki Editorial Team

Declaration files (.d.ts) describe the shape of JavaScript modules so TypeScript can provide type checking and autocompletion for untyped code. Every npm package that ships TypeScript types — either bundled or via @types/ — has a declaration file at its core.

This guide covers writing declaration files, ambient declarations, module augmentation, triple-slash directives, and publishing type definitions.

Declaration File Basics

A declaration file contains only type information — no implementation. It uses the declare keyword to describe the shape of existing code:

// types.d.ts
declare function calculateTax(amount: number, rate: number): number;

declare class Calculator {
    constructor(initialValue: number);
    add(x: number): number;
    getResult(): number;
---

Ambient Declarations

Ambient declarations describe code that exists at runtime but is not written in TypeScript. Use declare module to describe npm packages:

// globals.d.ts
declare module "my-custom-lib" {
    export function doSomething(config: Record<string, unknown>): void;
    export const VERSION: string;
---

Triple-Slash Directives

Triple-slash directives reference other type declarations. They are used primarily in older codebases and global declaration files:

/// <reference types="node" />
/// <reference path="./custom-types.d.ts" />

The types directive references a package in @types/. The path directive references a local file.

Module Augmentation

Module augmentation extends the types of existing modules without modifying their source. This is useful for adding custom properties to library types — for example, adding req.user to Express requests:

// express-augmentation.d.ts
import "express";

declare module "express" {
    interface Request {
        user?: {
            id: string;
            name: string;
            role: "admin" | "user";
        };
    }
---

Global Augmentation

Global augmentation adds declarations to the global scope. Use this to extend built-in types or add global variables:

// global-augmentation.d.ts
declare global {
    interface String {
        toTitleCase(): string;
    }

    const APP_CONFIG: {
        apiUrl: string;
        environment: "development" | "production";
    };
---

Declaration Merging

TypeScript merges multiple declarations with the same name. Interface merging is particularly useful — you can declare an interface in multiple files and TypeScript combines them:

// file1.d.ts
interface Window {
    myApp: { version: string };
---

// file2.d.ts
interface Window {
    analytics: { track(event: string): void };
---

// Both properties are available on Window

Writing Declarations for Libraries

Using @types from DefinitelyTyped

Most popular libraries have community-maintained types published to @types/. Install them with npm:

npm install --save-dev @types/express @types/lodash @types/react

If a library bundles its own types (indicated by the types field in package.json), you do not need the @types/ package.

Writing Custom Declarations

When types are not available on DefinitelyTyped, write minimal declarations covering only the APIs you use. Full-structure declarations are ideal but time-consuming to maintain:

// minimal-declarations.d.ts
declare module "legacy-library" {
    export function init(config: { apiKey: string }): void;
    export function track(event: string, properties?: Record<string, unknown>): void;
---

Publishing Types

When publishing an npm package, include declaration files by setting declaration: true in tsconfig.json. TypeScript generates .d.ts files alongside compiled JavaScript. Point consumers to your types via the types field in package.json:

{
    "name": "my-library",
    "main": "dist/index.js",
    "types": "dist/index.d.ts"
---

Advanced Declaration Patterns

Declaring Namespaces

For legacy code that uses namespaces instead of modules:

declare namespace MyApp {
    namespace Models {
        interface User {
            id: number;
            name: string;
        }
    }
    
    namespace Utils {
        function formatDate(date: Date): string;
    }
---

Declaring Enums

Describe existing JavaScript enum-like objects:

declare const Colors: {
    Red: "red";
    Green: "green";
    Blue: "blue";
---;

type Colors = (typeof Colors)[keyof typeof Colors];

Declaring Classes

Use declare class to describe JavaScript classes:

declare class EventEmitter {
    static defaultMaxListeners: number;
    
    on(event: string, listener: (...args: any[]) => void): this;
    emit(event: string, ...args: any[]): boolean;
    removeListener(event: string, listener: (...args: any[]) => void): this;
---

Best Practices

  • Prefer interface over type for object shapes that might be extended
  • Use generic constraints to provide accurate types for parameterized APIs
  • Mark optional properties with ? and readonly properties with readonly
  • Use unknown instead of any for truly flexible types — it forces type checking before use
  • Include JSDoc comments in declaration files — they appear in editor tooltips
  • Test your declarations by consuming them in a separate TypeScript file

Summary

Declaration files bridge TypeScript’s type system with existing JavaScript code and libraries. Use ambient declarations for describing global code, module augmentation for extending existing types, and triple-slash directives for referencing other declarations. When writing a library, enable declaration: true to generate types automatically.

FAQ

What is the difference between a .d.ts file and a regular .ts file? A .d.ts file contains only type declarations — no implementation. It describes the shape of existing code for TypeScript’s type checker. A .ts file contains both types and implementation and is compiled to JavaScript.

How do I fix “Cannot find module” errors for a library? First check if the library bundles types. If not, install @types/library-name. If types are not available on DefinitelyTyped, create a local declaration file with declare module "library-name" { ... }.

Can I use TypeScript to generate declaration files? Yes. Set declaration: true in tsconfig.json. The compiler generates .d.ts files for all your .ts files automatically. For libraries, also set declarationMap: true for better IDE navigation.

How do I handle version mismatches between a library and its types? Keep the @types/ version in sync with the library version. The DefinitelyTyped project maintains versions alongside library releases. Use your package manager’s version pinning to lock both.

Declaration File Structure

Declaration files (.d.ts) describe the shape of JavaScript modules to TypeScript. A well-structured declaration file includes:

Module Declarations

// types/mylib.d.ts
declare module "mylib" {
    export function doSomething(options: Options): Result;
    export interface Options {
        timeout: number;
        retries?: number;
    }
    export interface Result {
        success: boolean;
        data: unknown;
    }
---

Augmenting Existing Modules

When a library is missing types, augment it using module augmentation:

// types/augmentations.d.ts
import "express";

declare module "express" {
    interface Request {
        user?: { id: string; role: "admin" | "user" };
    }
---

Module augmentation is also useful for adding types to ambient modules, polyfilling missing exports, or extending library interfaces with custom properties. Place augmentations in a separate file and include it via the files or include field in tsconfig.

Global Declarations

For scripts loaded via <script> tags (not modules), use global declarations:

// types/globals.d.ts
declare const API_KEY: string;
declare function trackEvent(name: string, properties?: Record<string, unknown>): void;

Global declarations should be used sparingly — prefer module-based declarations that support explicit imports and avoid polluting the global namespace.

Tripe-Slash Directives

Triple-slash directives are TypeScript compiler directives embedded in comments:

/// <reference path="./types.d.ts" />
/// <reference types="node" />
/// <reference lib="es2021" />

These are primarily needed for declaration files and legacy code. Modern TypeScript projects should use tsconfig.json files, include, and types fields instead — they provide better tooling support and are more maintainable.

Generating Declaration Files

When creating a TypeScript library, configure tsconfig to emit declaration files automatically:

{
    "compilerOptions": {
        "declaration": true,
        "declarationDir": "./dist/types",
        "emitDeclarationOnly": false
    }
---

For mixed JavaScript/TypeScript codebases, enable allowJs and checkJs to gradually add type checking to JavaScript files. Use // @ts-check at the top of individual JS files for incremental adoption. The TypeScript compiler infers types from JSDoc comments and JavaScript patterns, producing declaration files that provide editor completions for JavaScript consumers.

Publishing Types to npm

When publishing a package, include types by setting the types field in package.json:

{
    "name": "@myorg/my-lib",
    "main": "./dist/index.js",
    "types": "./dist/index.d.ts",
    "files": ["dist"]
---

For dual CJS/ESM packages, add exports field with type information:

{
    "exports": {
        ".": {
            "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" },
            "require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
        }
    }
---

This ensures consumers get proper type checking regardless of their module system.

Practical Type Patterns for Large Codebases

When building large TypeScript applications, several patterns keep type complexity manageable:

Discriminated unions for API responses — Model every API endpoint as a union of success, error, and loading states:

type AsyncState<T, E = Error> =
    | { status: "idle" }
    | { status: "loading" }
    | { status: "success"; data: T }
    | { status: "error"; error: E };

This pattern eliminates impossible states — you cannot accidentally access data when status is "loading". The compiler enforces exhaustive handling of all states, preventing common bugs.

Zod for runtime validationTypeScript types are compile-time only. Use Zod to validate external data at runtime and infer the type:

import { z } from "zod";

const UserSchema = z.object({
    id: z.string().uuid(),
    email: z.string().email(),
    role: z.enum(["admin", "user"]),
---);
type User = z.infer<typeof UserSchema>;

// Validate API response at runtime
const user = UserSchema.parse(apiResponse);
// user is fully typed as User

This dual approach provides runtime safety for data entering your system (API responses, file parsing, user input) and compile-time safety everywhere else.

Avoiding Type Complexity Traps

Complex types (nested conditionals, deep mapped types, recursive type aliases) can make code harder to understand. Apply these guidelines:

  • Keep conditional types shallow — avoid nesting beyond 2-3 levels
  • Name intermediate types instead of inlining complex expressions
  • Prefer interfaces over type aliases for object shapes (interfaces have better error messages and are faster to check)
  • Use type for unions, intersections, and mapped types
  • Extract reusable type utilities into a shared types module

Related: TypeScript Module System | TypeScript Config

Section: TypeScript 1495 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top