TypeScript Declaration Files (.d.ts)

A declaration file is a TypeScript file that ends in .d.ts and contains nothing but type information — no function bodies, no executable statements, no runtime code at all. It is TypeScript’s way of describing the shape of JavaScript: the parameters a function takes, the properties an object has, the exports a module provides. Every time you get autocomplete for Array.prototype.map, or type-checking for a library like Express or lodash that is itself written in plain JavaScript, a declaration file is doing the work behind the scenes.

This lesson explains how declaration files are produced, how to read and write them yourself, and the mistakes that catch almost every developer the first time they hand-write one.

Overview: How Declaration Files Work

Structurally, a .d.ts file looks like a normal TypeScript file with all the implementations removed. Where an ordinary .ts file might write function double(n: number): number { return n * 2; }, the matching declaration says only function double(n: number): number; — a signature with a semicolon instead of a body. TypeScript calls this an ambient declaration: a promise that a value with this exact shape exists somewhere at runtime, without TypeScript needing to see, or check, how it is implemented. Inside a .d.ts file, every top-level declaration is implicitly ambient — you don’t need the declare keyword in front of function, const, or class, because the whole file is already a declaration context. You still need declare in an ordinary .ts file when you want to describe something without implementing it there.

TypeScript finds declaration files from four places, and this list explains almost every real-world .d.ts scenario you’ll run into: (1) files tsc generates automatically from your own .ts source when the declaration compiler option is on; (2) declaration files bundled directly inside an npm package, pointed to by the types (or the older typings) field in its package.json; (3) community-maintained declaration files from the DefinitelyTyped project, installed as separate @types/<package> packages and picked up automatically from node_modules/@types; and (4) declaration files you write by hand — usually to type an untyped legacy script, or to extend types that already exist.

Because a .d.ts contains no runtime code, everything in it is erased before your program ever runs — declaration files don’t compile to anything. They exist purely to give the type checker, and your editor’s autocomplete, something to check against. This also means TypeScript never verifies that a .d.ts matches its real implementation. If you hand-write a declaration that lies — claiming a function returns a string when it actually returns undefined — TypeScript will happily type-check code that then crashes at runtime. Trusting the declaration completely, with no cross-check against the implementation, is the single most important thing to understand about how .d.ts files work.

One subtlety trips people up constantly: whether a .d.ts file, or any TypeScript file, is treated as a module or a global script depends entirely on whether it contains a top-level import or export. A file with neither declares everything into the global scope — useful for typing browser globals or legacy scripts, dangerous if done by accident. A file with at least one import or export is a module, and everything inside it is scoped to that module unless explicitly re-exposed with a declare global block. Adding a no-op export {}; to a would-be-global file is a common trick used specifically to flip a file into module mode.

Syntax

Declaration files are built from a small vocabulary of declare forms, plus the interface and type constructs you already know. The table below covers the forms you’ll actually use.

Form What it describes
declare const/let/var x: T; An existing variable or global binding, with no initializer allowed.
declare function f(a: T): R; A function signature (can be overloaded with multiple declare function lines).
declare class C { ... } A class’s public shape — constructor, properties, method signatures, no bodies.
interface / type Ordinary type definitions; identical syntax to what you use in .ts files.
declare namespace N { ... } Groups related ambient declarations under a dotted name (legacy pattern, mostly superseded by ES modules).
declare module "specifier" { ... } Describes an external module matched by the exact string used in import ... from "specifier".
declare global { ... } Inside a module file, merges declarations back into the global scope.
export = value; A CommonJS-style single export, paired with import x = require("specifier") on the consuming side.
/// <reference types="pkg" /> A triple-slash directive that pulls in another package’s ambient declarations.

Examples

Example 1: Letting tsc generate the declaration file for you

The most common way declaration files come into existence is that you never write one — you write ordinary TypeScript, turn on the declaration compiler option, and tsc emits a matching .d.ts alongside the compiled JavaScript. Here is a small implementation file:

export interface Point {
  x: number;
  y: number;
}

export function distance(a: Point, b: Point): number {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

export const origin: Point = { x: 0, y: 0 };

console.log(distance(origin, { x: 3, y: 4 }));

Output:

5

Compiling this with tsc --declaration produces two files: the ordinary .js output, and a geometry.d.ts containing only the shapes, with every function body stripped out:

export interface Point {
    x: number;
    y: number;
}
export declare function distance(a: Point, b: Point): number;
export declare const origin: Point;

Notice the generated file keeps every type but drops the arithmetic entirely — distance becomes a bare signature. This is the safest way to produce a declaration file: it can never drift out of sync with your implementation, because it is derived from it automatically on every build.

Example 2: Hand-writing a declaration file for an untyped JS module

Sometimes you depend on a JavaScript file or package that ships no types at all, and no @types package exists for it either. You can write your own ambient module declaration that matches the exact import specifier. On its own this does not execute anything — it just tells TypeScript what shape to expect:

declare module "slugify-lite" {
  export interface SlugifyOptions {
    lower?: boolean;
    separator?: string;
  }

  export default function slugify(input: string, options?: SlugifyOptions): string;
}

Output:

// (no output — a declaration file contains no executable statements;
// it exists purely for the type checker)

Once this file is anywhere in your program’s compiled scope — for example, types/slugify-lite.d.ts, included via your tsconfig.json — any file that writes import slugify from "slugify-lite"; gets full autocomplete and type-checking against this shape, even though the real slugify-lite package on disk is plain, untyped JavaScript. TypeScript matches the ambient module purely by comparing the string "slugify-lite" to the import specifier — it never reads the real package, which is exactly why a wrong hand-written declaration can silently lie.

Example 3: Extending an existing type with declaration merging

Declaration files are not only for brand-new types — they can also augment types that already exist, including built-in globals. This uses declare global combined with a real runtime implementation, so unlike the previous two examples, this one actually runs:

export {};

declare global {
  interface Array<T> {
    last(): T | undefined;
  }
}

Array.prototype.last = function <T>(this: T[]): T | undefined {
  return this[this.length - 1];
};

const scores = [10, 20, 30];
console.log(scores.last());

const empty: number[] = [];
console.log(empty.last());

Output:

30
undefined

The empty export {}; at the top forces this file to be treated as a module, which is what allows declare global to legally merge a new last() method onto the built-in Array<T> interface everywhere in the program. The interface merge only adds a type — it does not create the method, so the code still has to assign a real implementation onto Array.prototype for it to work at runtime. Forgetting that second half is the most common way this pattern goes wrong, and it’s covered in Common Mistakes below.

Under the Hood: How TypeScript Resolves and Uses Declaration Files

  1. Module resolution runs first. When your code writes import { x } from "some-module", TypeScript looks for a matching source file using the configured moduleResolution strategy — trying real .ts/.tsx files first, then .d.ts files, inside the package itself, then in node_modules/@types/some-module, then against any ambient declare module "some-module" block visible anywhere in the program.
  2. The declaration is trusted completely. Once found, TypeScript builds its type information purely by reading the signatures in the .d.ts — it never opens or analyzes the real .js implementation behind it.
  3. Type-checking proceeds normally. Your call sites, argument counts, and return-value usage are all checked against the ambient signatures exactly as they would be against a real .ts file.
  4. Emission is the reverse process. If declaration is enabled in tsconfig.json, tsc strips every function body from your own code, keeps every type, and writes the result out as a parallel .d.ts — plus an optional .d.ts.map file when declarationMap is on, which lets editors jump from the declaration straight to your real source.
  5. None of it survives to runtime. Declaration files are never converted to JavaScript — they are deleted from the picture the moment tsc finishes type-checking. Type annotations in ordinary .ts files are erased too, but a .d.ts file never had runtime code to begin with.

Common Mistakes

Mistake 1: Using declare global without making the file a module

declare global only makes sense inside a module — a file with at least one import or export — because it explicitly asks TypeScript to reach out of a module’s local scope into the shared global one. A file with no import/export is already global by default, so the compiler rejects the augmentation:

declare global {
  interface Window {
    appVersion: string;
  }
}

console.log(window.appVersion);

This reports: error TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module declarations. The fix is a one-line addition — a no-op export {}; turns the file into a module, which makes the declare global block legal:

export {};

declare global {
  interface Window {
    appVersion: string;
  }
}

console.log(window.appVersion);

Mistake 2: Merging a type without providing the matching implementation

A type augmentation only adds type information — it never creates the underlying value. It’s easy to add a method to an interface, watch it type-check cleanly, and forget that nothing exists at runtime to back it up:

export {};

declare global {
  interface String {
    shout(): string;
  }
}

const message = "hello";
console.log(message.shout());

This compiles without any error at all — TypeScript trusts the merged String interface completely — but running it throws TypeError: message.shout is not a function, because String.prototype.shout was never actually assigned. The type and the implementation are two separate steps, and both are required:

export {};

declare global {
  interface String {
    shout(): string;
  }
}

String.prototype.shout = function (this: string): string {
  return this.toUpperCase() + "!";
};

const message = "hello";
console.log(message.shout());

Best Practices

  • Turn on "declaration": true (and "declarationMap": true) in tsconfig.json for any code you publish, so consumers get accurate, auto-generated types instead of you maintaining a .d.ts by hand.
  • Before hand-writing types for a third-party package, check whether an @types/<package> package already exists on npm — most popular untyped packages already have community-maintained declarations.
  • Keep hand-written declare module blocks narrow: only type the functions and options you actually use, not the library’s entire surface area speculatively.
  • Remember that TypeScript never validates a .d.ts against its real implementation — treat hand-written declarations as a contract you are personally responsible for keeping accurate.
  • Always pair declare global with export {}; (or a real import/export) and use it sparingly — it changes types everywhere in your program, which is easy to abuse.
  • Prefer ES module export/import syntax over legacy declare namespace and triple-slash references in new declaration files; keep those only for compatibility with older UMD-style libraries.
  • Set the "types" field in package.json to point at your library’s entry .d.ts so both TypeScript and bundlers can find it reliably.

Practice Exercises

  1. Write an ambient declaration for a hypothetical global function formatBytes(bytes: number): string (no implementation needed) that would let this line type-check: const label: string = formatBytes(2048);. Where would you put this file in a real project so tsc picks it up automatically?
  2. Given this implementation, write by hand the .d.ts that tsc --declaration would generate for it: export function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); }
  3. The following augmentation fails to compile with "Augmentations for the global scope can only be directly nested in external modules or ambient module declarations." Fix it with a one-line change: declare global { interface Number { isPositive(): boolean; } }

Summary

  • A .d.ts file contains only type information — no implementations, no runtime code — and is completely erased before anything executes.
  • TypeScript finds declarations from four places: auto-generated from your own source, bundled inside a package, installed separately via @types, or hand-written by you.
  • Inside a .d.ts file, declarations are implicitly ambient; declare is only needed to describe something without a body inside an ordinary .ts file.
  • A file becomes a module the moment it has a top-level import or export; declare global requires module mode to legally merge new members back into the global scope.
  • TypeScript trusts declaration files completely — it never checks them against a real implementation, so a wrong hand-written .d.ts type-checks fine and can still crash at runtime.
  • Prefer generating declarations with tsc --declaration over hand-writing them, and check for an existing @types package before typing a third-party library yourself.