.d.ts Files, Ambient Declarations, and Module Augmentation

Learn what .d.ts declaration files are, how declare creates ambient and global declarations, how to type untyped third-party packages, how module augmentation and declaration merging extend existing types, and how TypeScript generates and bundles declaration output.

A .d.ts file contains no runtime code at all — only type information. Declaration files are how TypeScript describes the shape of JavaScript it didn't compile itself: a library published as plain JS, a global variable injected by a <script> tag, or your own compiled package's public API surface for downstream consumers.

What .d.ts files are and how declare works

A declaration file (.d.ts) contains type declarations without implementations — function signatures with no bodies, variable declarations with no initializers, classes with no method bodies. It exists purely for the compiler and your editor; it is never emitted to JavaScript and never executed. When you import from a .ts module, TypeScript is really reading its (real or generated) .d.ts shape to type-check your usage.

The declare keyword tells TypeScript 'trust me, this exists at runtime, even though you don't see where it's defined here.' declare const API_URL: string; says a global API_URL will exist when the code runs — perhaps injected by a bundler's define plugin — without TypeScript needing to find or check an actual assignment. declare is a promise you're making to the compiler, not something the compiler can verify for you.

Because declare statements produce no code, using declare incorrectly is a common source of "works in the type checker, crashes at runtime" bugs — if you declare something that in fact doesn't exist at runtime, TypeScript will happily let you use it and the error only surfaces when the program actually runs.

Ambient and global declarations, and typing untyped packages

An ambient declaration describes something that exists in the global scope without an explicit import — window globals, script-tag-injected libraries, environment-specific APIs. A global.d.ts file (or any .d.ts with declare global { ... }) can extend the built-in global scope, for example adding a property to Window so window.myAnalytics type-checks across your whole app.

For a third-party package with no types at all, you write a custom module declaration: declare module "some-untyped-library" { export function doThing(x: string): void; } in a .d.ts file included in your project. This tells TypeScript what shape to assume for every import from that module name, without needing the package itself to ship any types.

A quicker (and less safe) escape hatch is declare module "some-untyped-library"; with no body, which makes every import from that module any-typed. This unblocks a build immediately but gives up all type safety for that package — reasonable as a temporary measure, but not a substitute for real types. For genuinely popular untyped packages, check the community-maintained @types/<package-name> packages (DefinitelyTyped) before writing your own — someone has often already done it.

Module augmentation and declaration merging

Declaration merging is a TypeScript feature where multiple declarations with the same name combine into one, rather than the second overwriting the first. Two interface Foo { ... } declarations with the same name in the same scope merge their members into a single interface — this is different from a type alias, which errors on a duplicate name.

Module augmentation uses this merging behavior deliberately to extend a type from a module you don't own. A classic example is adding a custom property to Express's Request type: declare module "express" { interface Request { user?: User; } } merges your addition into the existing Request interface wherever it's used, so req.user type-checks in every route handler without modifying Express's own source.

Augmentation only works cleanly with interfaces (because they merge) and requires the module specifier string to match exactly, and the augmenting file must itself be treated as a module (have at least one top-level import or export) for the declare module block to be recognized as an augmentation rather than a new ambient module declaration.

Declaration emit, triple-slash directives, and bundling

Setting "declaration": true in tsconfig.json makes the compiler generate a .d.ts file alongside every compiled .js file, mechanically derived from your source's inferred and annotated types. This is how a published npm package gives its consumers full type information without shipping its original .ts source — consumers import the .js and get IntelliSense from the paired .d.ts.

The generated declaration surface is a real artifact worth inspecting: overly broad inferred types, leaked internal types, or missing exports in your source all show up as problems in the emitted .d.ts, sometimes only visible once a consumer's build breaks. Reviewing generated declarations (or running tsc --declaration --emitDeclarationOnly locally) is a useful sanity check before publishing a library.

Triple-slash directives (/// <reference path="..." />, /// <reference types="..." />) are an older, pre-ES-module mechanism for pulling in another declaration file or an @types package's types. They're still legitimate inside hand-written .d.ts files — especially /// <reference types="node" /> at the top of a file that needs Node's global types — but in ordinary .ts application code, prefer import/import type over triple-slash directives wherever a module system is available.

For libraries with many internal .d.ts files, a declaration bundler (such as api-extractor or dts-bundle-generator) rolls the whole generated declaration graph into a single .d.ts entry point. This matters for larger published packages: it hides internal module structure from consumers, produces a cleaner single point of documentation, and avoids leaking file-layout details that might change between versions.

A hand-written .d.ts for an untyped library

declare module "legacy-lib" supplies a type shape for a package that ships no types of its own. The trailing export {} is required — without at least one import/export, TypeScript treats the file as a script and declare global behaves differently (or errors).

// legacy-lib.d.ts
declare module "legacy-lib" {
  export interface LegacyOptions {
    retries: number;
    timeout?: number;
  }

  export function connect(options: LegacyOptions): void;
}

// globals.d.ts
declare global {
  interface Window {
    myAnalytics: {
      track(event: string): void;
    };
  }
}

export {}; // makes this file a module so `declare global` is an augmentation

Code Examples

__APP_VERSION__ is injected by a bundler's define step (common in Vite/webpack builds) and never appears as a real variable declaration anywhere in the source. declare const tells TypeScript to trust that it will exist at runtime, so version.ts type-checks without an import.

declare const __APP_VERSION__: string;

Common mistake: If the build's define config is ever removed or the variable name changes, TypeScript will not catch the mismatch — declare is not verified against anything at runtime.

import "express" (a side-effect-only import) ensures this file is treated as a module, which makes declare module "express" merge into Express's existing Request interface rather than declare a brand-new, conflicting module. After augmentation, every Request in the project — including ones from Express's own middleware types — has the optional user property.

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

// env.d.ts
declare const __APP_VERSION__: string;

// version.ts
function printVersion() {
  console.log(`Running v${__APP_VERSION__}`);
}

printVersion();
// express-augment.d.ts
import "express";

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

// route.ts
import type { Request, Response } from "express";

function handler(req: Request, res: Response) {
  if (req.user?.role === "admin") {
    res.send("welcome, admin");
  } else {
    res.status(403).send("forbidden");
  }
}

Where .d.ts fits in the build

The .js and .d.ts are two separate artifacts produced from the same source — the .js is what actually runs, the .d.ts is what the type checker reads on the consuming side.

your-lib source (.ts)
         |
    tsc --declaration
         |
         +----------------+
         |                |
   dist/index.js    dist/index.d.ts
         |                |
         v                v
   consumer runs JS   consumer's editor/tsc
   (no types here)    reads types from here

Important Rules

Only use declare for things you are certain exist at runtime through some mechanism outside TypeScript's view (bundler define, injected script, ambient global) — it is unchecked by design.

Prefer @types/<package> from DefinitelyTyped over hand-writing a declaration for a popular untyped package; write your own only when no community types exist.

Use declare module "name" { interface X { ... } } (module augmentation) to extend a type you don't own; never edit a library's own shipped .d.ts files directly.

A file performing declare global must itself be a module (contain a top-level import or export, even a bare export {}) or the augmentation will not merge as intended.

Prefer ES module import/import type over triple-slash /// <reference /> directives in ordinary application code; reserve triple-slash directives for hand-authored .d.ts files that need another ambient type library.

Where this shows up

Every TypeScript package published to npm ships .d.ts files generated from its source so consumers get autocomplete and type errors without installing the original TypeScript project. Inside applications, teams commonly maintain a small types/ folder of hand-written .d.ts files: one for environment globals injected by the build tool, one augmenting a framework's request/context type with app-specific fields, and occasionally one giving shape to a legacy or internal package that predates TypeScript.

declare module "chart-lib"; // bare, no shape at all declare module "chart-lib" { export function render(el: HTMLElement): void; }

A bodiless declare module makes every import from that module any, silently discarding all type checking for it — fine as a stopgap, but it should be replaced with an actual shape (or real @types) as soon as possible.

// augment.d.ts with declare global but no import/export in the file

// augment.d.ts export {}; declare global { interface Window { myFlag: boolean; } }

Without at least one top-level import or export, TypeScript treats the file as a script rather than a module, and declare global inside a script file is redundant/ineffective as an augmentation — the empty export {} is what makes the file a module.

[multiple-choice] What does a .d.ts file actually contain? A. Runtime JavaScript code that TypeScript compiles and executes B. Only type declarations, with no implementations, never emitted or executed C. A minified bundle of a library's compiled output D. Only global variable declarations, nothing else

B. Only type declarations, with no implementations, never emitted or executed — Declaration files hold type information only — function signatures without bodies, variables without initializers — and produce no runtime output; they exist purely for the type checker and editor tooling.

[true-false] Declaring declare const API_URL: string; in a .d.ts file guarantees that TypeScript will verify a real API_URL variable exists at runtime. A. True B. False

B. False — declare is an unchecked assertion to the compiler — it tells TypeScript to trust that the value exists elsewhere (e.g. injected by a bundler), but TypeScript never verifies this, so a mismatch only shows up as a runtime error.

[multiple-choice] Which technique correctly adds a custom user property to Express's existing Request interface without editing Express's own source? A. Redefining type Request = { user: User } in your own file B. Module augmentation: declare module "express" { interface Request { user?: User; } } in a file treated as a module C. Extending HTMLElement with a user property D. Using any everywhere req is referenced

B. Module augmentation: declare module "express" { interface Request { user?: User; } } in a file treated as a module — Module augmentation relies on TypeScript's interface declaration merging: declaring interface Request again inside declare module "express" merges your new member into the existing interface everywhere it's used, as long as the file is recognized as a module.

Write a declaration file that gives a shape to an untyped module "sticker-picker" with a single exported function openPicker(onSelect: (emoji: string) => void): void, then write a .ts file that imports and calls it.

// sticker-picker.d.ts declare module "sticker-picker" { export function openPicker(onSelect: (emoji: string) => void): void; } // app.ts import { openPicker } from "sticker-picker"; openPicker((emoji) => { console.log("picked:", emoji); });

Use declare module "sticker-picker" { export function openPicker(onSelect: (emoji: string) => void): void; } in a .d.ts file, then a normal import { openPicker } from "sticker-picker"; in your .ts file.