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
.tsfiles, andtscgenerates any.d.tsyou 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.tsinside 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
- Write a
globals.d.tsdeclaring an ambientconst __BUILD_ID__: stringand use it in a.tsfile; confirm it type-checks with no import. - Augment
Windowwith adataLayer: unknown[]property and accesswindow.dataLayer. - Augment a library interface (real or mock) to add a property, and confirm the property is known
everywhere without
as any. - Write
declare global { interface Array<T> { last(): T | undefined } }, forget theexport {}, read the error, then add it. - Compile a tiny library with
"declaration": trueand inspect the generated.d.tsnext to the.js. - "Go to definition" on a function from a typed library and read the
.d.tsyou land in — practise reading declarations as documentation.
Official documentation
- TypeScript — Declaration Files: Introduction — The full guide to authoring
.d.ts. - TypeScript — Declaration Merging — How module augmentation and interface merging work.
- TypeScript — Publishing types (
typesfield) — Shipping declarations with a library.
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