RizTech Academy logo
RizTech Academy
TypeScript in Real ProjectsLesson 3 of 630 min

Declaration files: .d.ts, ambient types and module augmentation

So far every type you have written lived alongside the code it described. But TypeScript also has files that contain only types and no runtime code — declaration files, ending in .d.ts. They are how types travel: how a compiled JavaScript library tells TypeScript its shapes, how you describe a global that some script added, and how you extend types that someone else defined. Understanding them turns "why does my editor know about express?" from magic into mechanism.

What a .d.ts file is

A declaration file describes types without implementing them. It contains declared shapes — the compiler reads them for checking, and they emit no JavaScript, because there is nothing to run:

// math-utils.d.ts — types only, no bodies
export declare function add(a: number, b: number): number;
export declare function clamp(value: number, min: number, max: number): number;
export declare const PI: number;

Compare that to a normal .ts file, which has both types and implementations. A .d.ts is pure description. You meet them everywhere: when you compile a library with "declaration": true (the tsconfig lesson), tsc emits a .d.ts next to each .js so consumers of your library get types. And every @types/* package is a bundle of .d.ts files describing a JavaScript library that ships no types of its own (the next lesson).

Why declaration files exist: types for compiled JavaScript

The core problem: you ship a library as JavaScript (that is what runs), but your users write TypeScript and want types. The .js has no types — they were erased at compile time. The .d.ts carries them separately:

my-lib/
  index.js        ← the runtime code (what actually executes)
  index.d.ts      ← the types (what TypeScript reads for checking)

A package.json points at both — "main": "index.js" for the runtime, "types": "index.d.ts" for the compiler. When someone imports your library, Node loads the .js and TypeScript reads the .d.ts. This separation is the whole reason .d.ts exists: runtime and types ship side by side but separately, so a JavaScript library can be fully typed for TypeScript users without changing what runs.

Ambient declarations: describing what already exists

declare also describes things that exist at runtime but that TypeScript otherwise cannot see — ambient declarations. The classic case: a script tag added a global, or a build tool injects a constant.

// globals.d.ts
declare const __APP_VERSION__: string;   // injected by the bundler at build time
declare function gtag(command: string, ...args: unknown[]): void;  // added by a <script>

interface Window {
  dataLayer: unknown[];   // a property some analytics script attaches to window
}

Now __APP_VERSION__ and gtag type-check across your project, and window.dataLayer is known — even though nothing in your TypeScript defines them; they appear at runtime from outside. declare means "trust me, this exists" — it is a promise to the compiler about the runtime, with the same character (and the same risk if wrong) as any assertion. Ambient declarations are how you make the compiler aware of a world it cannot otherwise inspect.

Module augmentation: extending types you do not own

The most powerful use is adding to types someone else declared, without editing their files — module augmentation. You reopen a module's declaration and add to it. The common real case: a library defines an interface and expects you to extend it.

// Express lets you attach your own properties to the request object.
// You TELL TypeScript about them by augmenting Express's own types:
import "express";

declare module "express-serve-static-core" {
  interface Request {
    user?: { id: string; role: "admin" | "member" };   // added by your auth middleware
  }
}

After this, req.user type-checks everywhere in your app, because you extended Express's Request interface rather than fighting it with as any. This works because interfaces are open — declaring an interface with the same name in the same module merges into it (the declaration-merging behaviour). It is the sanctioned way to type "this library, plus what my middleware adds", and every real Express or Fastify codebase uses it.

You can augment the global scope too:

declare global {
  interface Array<T> {
    last(): T | undefined;   // if you polyfilled Array.prototype.last
  }
}
export {};   // makes this file a module, required for `declare global`

The export {} is a common gotcha: declare global only works inside a module, and a file with no imports/exports is a script, not a module — the empty export makes it one.

When you write them, and when you do not

Be clear about the split, because most application developers rarely write .d.ts by hand:

  • You do not usually write them for your own app code — your types live in normal .ts files, and tsc generates any .d.ts you need when you build a library.
  • You write them by hand for: ambient globals (a script-tag global, a build-time constant), module augmentation (extending a library's types), and describing an untyped JavaScript file you cannot convert.
  • You read them constantly: every @types/* package, and the .d.ts inside typed libraries, is where you look to understand what a library's API actually is. "Go to definition" on a library function lands you in its .d.ts — and now you can read it.

The mental model: .d.ts is the boundary layer between the typed world you write and the JavaScript world you consume. Most of the time you consume them; occasionally you write one to teach the compiler about something it cannot see.

Check your work

What a .d.ts file is. A declaration file — types only, no runtime code; emits no JavaScript.

Why they exist. A library ships as JavaScript (types erased); the .d.ts carries the types separately, pointed at by package.json's types field, so TypeScript users get checking.

What declare does. Describes something that exists at runtime but the compiler cannot see — an ambient declaration; a promise about the runtime, like an assertion.

What module augmentation is. Adding to an interface/module someone else declared, without editing their files — works because interfaces are open and same-name declarations merge. The standard way to type req.user on Express.

The declare global gotcha. It needs the file to be a module; add export {} if the file has no other imports/exports.

When you write versus read them. You mostly read them (@types, typed libraries); you write them by hand for ambient globals, module augmentation, and untyped JS you cannot convert.

Practice

  1. Write a globals.d.ts declaring an ambient const __BUILD_ID__: string and use it in a .ts file; confirm it type-checks with no import.
  2. Augment Window with a dataLayer: unknown[] property and access window.dataLayer.
  3. Augment a library interface (real or mock) to add a property, and confirm the property is known everywhere without as any.
  4. Write declare global { interface Array<T> { last(): T | undefined } }, forget the export {}, read the error, then add it.
  5. Compile a tiny library with "declaration": true and inspect the generated .d.ts next to the .js.
  6. "Go to definition" on a function from a typed library and read the .d.ts you land in — practise reading declarations as documentation.

Official documentation

Next: types for third-party libraries and DefinitelyTyped.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship