open atlas
↑ Back to track
TypeScript type system, deep TS · 07 · 03

.d.ts files: ambient declarations, augmentation, and the public type surface

`.d.ts` files describe shapes with no implementation: ambient declarations, `declare module`, `declare global`, and module augmentation. The traps: augmentation that silently fails to merge, accidental global pollution, and emitting types that reference private paths.

TS Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

You wanted to add a user field to Express’s Request so your auth middleware could type req.user. You wrote declare global { namespace Express { interface Request { user?: User } } } in types.ts — and the augmentation just didn’t apply; req.user stayed an error. The file had a top-level import, which made it a module, so declare global was scoped wrong and the merge silently failed. Declaration merging is unforgiving about where you declare things.

declare: shapes without implementations

A .d.ts file contains only type information — no executable code is allowed, so everything is declared. This is how you describe a value that exists at runtime but that TypeScript can’t see (a global from a script tag, an untyped npm package):

// globals.d.ts — ambient declarations (no import/export = a SCRIPT, global scope)
declare const __APP_VERSION__: string;     // injected by the bundler at build time
declare function gtag(...args: unknown[]): void;

interface Window {                          // merges into the built-in Window
  dataLayer: unknown[];
}

A file with no top-level import/export is a script: its declarations are global. A file with any top-level import/export is a module: its declarations are local unless you wrap them in declare global. This module-vs-script distinction is the root of most augmentation failures.

Ambient modules: typing an untyped dependency

When you import a JS package that ships no types and has no @types/..., TypeScript errors TS7016. declare module "name" invents an ambient module declaration to fill the gap:

// shims.d.ts
declare module "legacy-chart" {
  export interface ChartOptions { width: number; height: number }
  export function render(el: HTMLElement, opts: ChartOptions): void;
  const _default: { version: string };
  export default _default;
}

A wildcard form types whole classes of imports — e.g. asset imports under a bundler:

declare module "*.svg" {
  const url: string;     // a Vite/webpack loader turns the file into a URL string
  export default url;
}

Module augmentation: extending someone else’s types

To add to a type that another module exports, re-open that module with declare module from inside your own module (the file must have its own import/export):

// augment.ts — has imports, so it's a MODULE
import "express"; // ensure the target module is loaded
declare module "express-serve-static-core" {
  interface Request { user?: { id: string; roles: string[] } } // merges into Express's Request
}
export {}; // belt-and-braces: guarantees module context

For global types (Window, Array, globalThis), you must wrap the augmentation in declare global — and the enclosing file must still be a module:

// env.d.ts
export {}; // makes this a module so `declare global` is legal here
declare global {
  interface Window { __APP_VERSION__: string }
  namespace NodeJS { interface ProcessEnv { DATABASE_URL: string } }
}

Emitting and shipping types

When you publish a library, the .d.ts files you ship are your API contract for every consumer’s editor and type checker. Get the emit wrong and consumers will see TS4023 errors or broken “go to definition” — even when your runtime is fine.

declaration: true makes tsc emit a .d.ts next to each .js. declarationMap: true emits a .d.ts.map so a consumer’s “go to definition” jumps to your real source, not the generated .d.ts. In package.json you point consumers at them via types (or the types condition in exports):

{
  "types": "./dist/index.d.ts",
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }
}

typeRoots/types (compiler options, distinct from the package.json field) control which @types packages are included: types: [] disables auto-inclusion of every @types/* in node_modules, which is a common fix for “a global from some transitive @types polluted my project”.

Why this works

Why does a public export referencing an unexported type cause TS4023: Exported variable has or is using name 'X' from external module but cannot be named? Because the consumer’s .d.ts must be able to name every type in your public surface. If export function make(): Internal returns a type Internal you imported but never exported, the generated .d.ts would reference a path the consumer can’t import — a broken public API. The fix is to export Internal too, or inline its shape. This is also why shipping types that reference private/src paths breaks: the published .d.ts points at files not in the package.

export type vs export

In a .d.ts you author by hand, prefer export type { Foo } for type-only exports so consumers (and single-file transpilers) know it carries no runtime value. A plain export { Foo } where Foo is a type works for type consumers but is ambiguous to tools that erase types per-file.

Order the steps

Order the steps to correctly augment Express's Request with a `user` field so the merge actually applies:

  1. 1 Make the file a module: give it a top-level import/export (or add `export {}`)
  2. 2 Open the right target module: `declare module "express-serve-static-core"`
  3. 3 Re-declare the interface Request { user?: ... } so it MERGES with the original
  4. 4 Ensure tsconfig includes the .d.ts (via include/files) so the augmentation is loaded
Quiz

You add `declare global { interface Window { x: number } }` to a file that already has `import { z } from './z'` at the top, but no `export`. Why might the augmentation behave unexpectedly?

Recall before you leave
  1. 01
    What is the module-vs-script rule for .d.ts files, and why does it break augmentation?
  2. 02
    How do you type an untyped dependency vs extend an already-typed one?
  3. 03
    What does declaration emit ship, and what are the leak/pollution traps?
Recap

You can now author and ship a clean type surface: .d.ts carries types only, the module-vs-script rule decides whether your declarations are global or local, declare module and declare global extend dependencies and globals (when the context is right), and declaration/declarationMap emit a public surface that must not leak private paths. Next, project references — when one codebase grows into many packages, how does tsc --build order the work, share types across packages, and stay fast with .tsbuildinfo? Now when you see an augmentation that silently has no effect, check the context first — a stray import or a missing export {} is almost always the reason.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.