Chapter 17

Declaration Files: Writing .d.ts and Publishing @types

What Declaration Files Are

.d.ts files are type-only files โ€” they contain nothing but type declarations, no runtime code, and they produce no JavaScript output. Their job is to tell TypeScript the type shape of a JavaScript module or global variable, enabling type checking and autocomplete when you use it.

// math-utils.js (JavaScript library, no types)
function add(a, b) { return a + b; }
function clamp(value, min, max) { return Math.min(Math.max(value, min), max); }
module.exports = { add, clamp };
// math-utils.d.ts (corresponding declaration file)
export declare function add(a: number, b: number): number;
export declare function clamp(value: number, min: number, max: number): number;

TypeScript looks for .d.ts files in this order:

  1. Path specified by types or typings field in package.json
  2. index.d.ts in the package root
  3. node_modules/@types/package-name/index.d.ts (from DefinitelyTyped)

Global Declarations

When a JS library injects global variables via <script> tags, global declarations tell TypeScript those globals exist.

// globals.d.ts

// Global variable
declare var __DEV__: boolean;
declare var __VERSION__: string;

// Global function
declare function log(message: string, level?: "info" | "warn" | "error"): void;
declare function require(module: string): unknown;

// Global class
declare class EventEmitter {
  on(event: string, listener: (...args: unknown[]) => void): this;
  off(event: string, listener: (...args: unknown[]) => void): this;
  emit(event: string, ...args: unknown[]): boolean;
}

// Global namespace (for globals with sub-properties)
declare namespace MyApp {
  interface Config {
    apiUrl: string;
    timeout: number;
  }
  
  function init(config: Config): void;
  function destroy(): void;
}

Usage:

// No import needed โ€” use directly
if (__DEV__) {
  log("Debug mode active", "info");
}

MyApp.init({ apiUrl: "https://api.example.com", timeout: 5000 });

Module Declarations

To type a JavaScript module, use declare module "module-name" syntax.

Basic module declaration

// Declaring types for "lodash" (simplified โ€” actual @types/lodash is much larger)
declare module "lodash" {
  export function chunk<T>(array: T[], size?: number): T[][];
  export function flatten<T>(array: (T | T[])[]): T[];
  export function debounce<T extends (...args: unknown[]) => unknown>(
    func: T,
    wait?: number
  ): T;
  
  // Default export
  const _: {
    chunk: typeof chunk;
    flatten: typeof flatten;
    debounce: typeof debounce;
  };
  export default _;
}

Wildcard module declarations

Handle CSS modules, image files, SVGs, and other non-JS assets:

// Allow TypeScript to accept .css module imports
declare module "*.css" {
  const styles: { [className: string]: string };
  export default styles;
}

// SVG as a React component (Vite's ?react suffix)
declare module "*.svg?react" {
  import React from "react";
  const SVGComponent: React.FC<React.SVGProps<SVGSVGElement>>;
  export default SVGComponent;
}

// Image assets
declare module "*.png" {
  const src: string;
  export default src;
}

declare module "*.json" {
  const value: unknown;
  export default value;
}

Augmenting Existing Types (Module Augmentation)

You don't need to replace existing type declarations โ€” you can add to them. This is the standard way to attach custom properties to framework objects.

Augmenting Express's Request object

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

declare module "express" {
  interface Request {
    user?: {
      id: string;
      email: string;
      roles: string[];
    };
    requestId?: string;
    startTime?: number;
  }
}

Using it in middleware:

// middleware/auth.ts
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "./jwt";

export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "Unauthorized" });
  
  req.user = verifyToken(token); // correctly typed, no need for as any
  next();
}

Augmenting global namespaces

// Extend ProcessEnv with known environment variable types
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: "development" | "production" | "test";
    DATABASE_URL: string;
    PORT?: string;
  }
}

Triple-Slash References

Reference other type dependencies inside declaration files using /// <reference .../> syntax.

// Reference a types package
/// <reference types="node" />
/// <reference types="jest" />

// Reference another declaration file
/// <reference path="./globals.d.ts" />

In modern TypeScript projects, triple-slash references are mainly used in two cases:

  1. Global declaration files that need to reference other type libraries
  2. A library's .d.ts entry file that aggregates multiple sub-declaration files

For most situations, using the types field in tsconfig.json or plain import statements is sufficient.


Practical Example: Writing a .d.ts for a Real JS Library

Using a simple utility library string-utils as a step-by-step example.

The JavaScript library (string-utils/index.js):

"use strict";

function truncate(str, maxLength, ellipsis) {
  ellipsis = ellipsis || "...";
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength - ellipsis.length) + ellipsis;
}

function slugify(str) {
  return str
    .toLowerCase()
    .replace(/[^\w\s-]/g, "")
    .replace(/[\s_-]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

function template(str, vars) {
  return str.replace(/\{\{(\w+)\}\}/g, (_, key) => {
    return vars[key] !== undefined ? String(vars[key]) : "";
  });
}

module.exports = { truncate, slugify, template };

Step 1: Analyze the export structure

This library uses CommonJS module.exports, with named exports accessible both as properties and as the whole object.

Step 2: Write the basic function signatures

// string-utils/index.d.ts (draft)

export declare function truncate(
  str: string,
  maxLength: number,
  ellipsis?: string
): string;

export declare function slugify(str: string): string;

export declare function template(
  str: string,
  vars: Record<string, string | number | boolean>
): string;

Step 3: Handle CommonJS compatibility

Because this library uses module.exports, TypeScript in CommonJS projects needs special handling:

// string-utils/index.d.ts (complete version, ESM-style named exports)
export declare function truncate(str: string, maxLength: number, ellipsis?: string): string;
export declare function slugify(str: string): string;
export declare function template(str: string, vars: Record<string, string | number | boolean>): string;

// For projects using require() directly, you can add:
// export = { truncate, slugify, template };
// But export = cannot be mixed with named exports โ€” pick one

For a purely CommonJS library, use export =:

// CommonJS-style declaration
interface StringUtils {
  truncate(str: string, maxLength: number, ellipsis?: string): string;
  slugify(str: string): string;
  template(str: string, vars: Record<string, string | number | boolean>): string;
}

declare const stringUtils: StringUtils;
export = stringUtils;

// Usage (with esModuleInterop: true):
// import stringUtils from "string-utils";
// const { truncate } = stringUtils;

Step 4: Point package.json at the types

{
  "name": "string-utils",
  "version": "1.0.0",
  "main": "index.js",
  "types": "index.d.ts"
}

export = vs export default

This is the most commonly confused area in declaration files.

// export = : CommonJS style, equivalent to module.exports = xxx
// Declaration file
declare function createServer(options: ServerOptions): Server;
export = createServer;

// Usage
import createServer = require("my-server");  // canonical form
import createServer from "my-server";         // requires esModuleInterop: true

// export default: ES Module style
// Declaration file
declare function createServer(options: ServerOptions): Server;
export default createServer;

// Usage
import createServer from "my-server";  // normal ESM import

Decision rule:


Publishing to DefinitelyTyped

If a library you use has no types, you can write declaration files and publish them to DefinitelyTyped, making them installable by everyone via @types/xxx.

Directory structure:

DefinitelyTyped/
โ””โ”€โ”€ types/
    โ””โ”€โ”€ string-utils/
        โ”œโ”€โ”€ index.d.ts          # main declaration file
        โ”œโ”€โ”€ index.d.ts.map      # source map (optional)
        โ”œโ”€โ”€ package.json        # package metadata
        โ””โ”€โ”€ tsconfig.json       # used for testing

types/string-utils/package.json:

{
  "private": true,
  "name": "@types/string-utils",
  "version": "1.0",
  "description": "TypeScript definitions for string-utils",
  "license": "MIT",
  "main": "",
  "types": "index.d.ts",
  "repository": {
    "type": "git",
    "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git",
    "directory": "types/string-utils"
  }
}

Test configuration (types/string-utils/tsconfig.json):

{
  "compilerOptions": {
    "module": "commonjs",
    "lib": ["es6"],
    "noImplicitAny": true,
    "noImplicitThis": true,
    "strictFunctionTypes": true,
    "strictNullChecks": true,
    "types": [],
    "noEmit": true,
    "strict": true
  },
  "files": ["index.d.ts"]
}

Validate with dtslint (DefinitelyTyped's official testing tool):

# Install
npm install -g dtslint

# Run tests from the types directory
dtslint types/string-utils

You can write type assertion comments inside the declaration file:

// index.d.ts
export declare function truncate(str: string, maxLength: number): string;

// $ExpectType string
truncate("hello world", 5);

// $ExpectError
truncate(123, 5); // wrong argument type โ€” should error

typesVersions: Different Types for Different TypeScript Versions

When your library needs to support older TypeScript while providing more precise types for newer versions:

{
  "name": "my-library",
  "version": "2.0.0",
  "types": "./dist/index.d.ts",
  "typesVersions": {
    ">=4.7": {
      "*": ["./dist/v4.7/*"]
    },
    ">=4.0": {
      "*": ["./dist/v4.0/*"]
    }
  }
}

TypeScript selects the type files matching the current compiler version.


@types/xxx vs Bundled Types

Situation Source Notes
package.json has a types field Library-bundled No extra install needed
Install @types/xxx DefinitelyTyped Community-maintained, may lag behind
Both exist Library-bundled wins Can override with types tsconfig option

TypeScript's resolution order: check package.json types/typings first, then @types, then index.d.ts in the directory.


Common Mistakes in Declaration Files

Mistake 1: Writing implementation code in a .d.ts file

// Wrong: .d.ts can only have types, not implementations
export declare function add(a: number, b: number): number {
  return a + b; // SyntaxError: declaration files cannot have function bodies
}

// Correct
export declare function add(a: number, b: number): number;

Mistake 2: Missing the declare keyword

// Wrong: in a declaration file's global scope, missing declare
var globalVar: string; // treated as a value declaration โ€” .d.ts can't have values

// Correct
declare var globalVar: string;

Mistake 3: Augmenting a module without importing it first

// Wrong: without import, this creates a new module instead of augmenting the existing one
declare module "express" {
  interface Request {
    user?: User;
  }
}

// Correct: must import the original module first (even an empty import)
import "express";

declare module "express" {
  interface Request {
    user?: User;
  }
}

Mistake 4: Mixing global and module declarations in one file

// If a file has import or export, it becomes a module file
// Global declarations in module files don't work

// Wrong (file has import, global declaration has no effect)
import { SomeType } from "./types";
declare var myGlobal: string; // not actually global

// Correct: wrap in declare global
import { SomeType } from "./types";
declare global {
  var myGlobal: string;
}

Summary Table

Syntax Purpose Example
declare var/let/const Global variable declare var __DEV__: boolean
declare function Global function declare function fetch(url: string): Promise<Response>
declare class Global class declare class EventEmitter { ... }
declare namespace Global namespace declare namespace MyApp { ... }
declare module "..." Module types declare module "lodash" { ... }
declare module "*.png" Wildcard module CSS Modules, image assets
export = CommonJS default export Corresponds to module.exports = xxx
export default ESM default export Corresponds to export default xxx
declare global { } Global declaration in a module file Augments global scope

Next Chapter

Chapter 18 covers runtime validation: TypeScript types only exist at compile time, but API responses, user inputs, and environment variables are unknown types at runtime. The zod library lets you define a schema that simultaneously provides runtime validation logic and a TypeScript type, eliminating this fundamental inconsistency.

Rate this chapter
4.9  / 5  (13 ratings)

๐Ÿ’ฌ Comments