Topic 11.3
Declaration Files (.d.ts)
In one line
A declaration file (.d.ts) holds only types and declare statements, no running code. It describes what a JavaScript file or a global offers, so TypeScript can check code that uses it. tsc can write them for you with declaration, and isolatedDeclarations makes that fast.
Think of it like this
The instruction leaflet in a medicine box. It doesn't contain medicine. It says what the box has, what it is for and how much to take. A declaration file is the leaflet for a piece of JavaScript: the code lives in a .js file, and the .d.ts next to it says what is in there and what types it takes and gives back. TypeScript reads the leaflet, never the medicine.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Declaration file
- A
.d.tsfile with only types anddeclarestatements. No running code. - declare
- A keyword that says "this exists at runtime, here is its type". It creates nothing at runtime.
- Ambient
- Describes something that already exists in the environment, outside this file.
- declaration (option)
- A tsconfig setting that makes tsc write a
.d.tsfor each.tsfile. - declarationMap
- Extra
.d.ts.mapfiles so the editor can jump from a.d.tsto the original.ts. - emitDeclarationOnly
- Write the
.d.tsfiles but not the.jsfiles. - isolatedDeclarations
- A rule that exports must be typed in writing, so declarations can be made from one file alone.
- Inferred type
- A type TypeScript works out from the code, instead of one you wrote.
Step by step
01A TypeScript file to describe
This src/index.ts is the tiny library. Look for what has no written type: the return type of makeUser, and the value of defaults.
export interface User {
id: number;
name: string;
}
const secret = "not exported";
export function makeUser(id: number, name: string) {
return { id, name, tag: secret.length > 0 ? "ok" : "bad" };
}
export class Counter {
#count = 0;
step = 1;
next(): number {
this.#count += this.step;
return this.#count;
}
}
export const defaults = { retries: 3, verbose: false } as const;02Turn on declaration
In tsconfig.json, set "declaration": true and "declarationMap": true (plus rootDir: "src" and outDir: "dist"). Run tsc -p .. It wrote three files (real run): index.js, index.d.ts, index.d.ts.map.
$ tsc -p . ; ls dist
index.d.ts index.d.ts.map index.js03Read the generated leaflet
Compare it with the source. The function body is gone. The unexported secret is gone (it was private). The return type that nobody wrote is now written out. The #count field became #private;: the shape of a class with a private field must be kept so classes stay distinct, but its name is a secret. as const became readonly literal types.
export interface User {
id: number;
name: string;
}
export declare function makeUser(id: number, name: string): {
id: number;
name: string;
tag: string;
};
export declare class Counter {
#private;
step: number;
next(): number;
}
export declare const defaults: {
readonly retries: 3;
readonly verbose: false;
};
//# sourceMappingURL=index.d.ts.map04Hand-write one for a JavaScript file
A plain JavaScript file legacy.js exports slugify and VERSION. Next to it, legacy.d.ts is the leaflet. TypeScript uses it for any import of ./legacy.js.
With it, slugify("Hello Big World") is fine, and slugify(42) is src/main.ts(4,21): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'. (real). The JavaScript, run by Node, printed hello-big-world 2.1.0 for the correct call.
export declare function slugify(text: string, sep?: string): string;
export declare const VERSION: string;05isolatedDeclarations: write the types down
Turn on "isolatedDeclarations": true (it needs declaration or composite). Now tsc 7.0.2 refuses exports whose declaration it could only produce by inferring across files (real output, for the first version of index.ts below).
TypeScript 6.0 words the first one as TS9007: Function must have an explicit return type annotation; 7.0 reports TS9013 on the returned expression. Either way, the cure is the same: write the types.
export function makeId(prefix: string) {
return prefix + "-1";
}
export const limits = { max: 10 };
export const parsed = Number("7");
// src/index.ts(2,10): error TS9013: Expression type can't be inferred with --isolatedDeclarations.
// src/index.ts(5,14): error TS9010: Variable must have an explicit type annotation with --isolatedDeclarations.
// fixed (limits is fine: an object literal of simple values):
export function makeId(prefix: string): string {
return prefix + "-1";
}
export const parsed: number = Number("7");Try it yourself
- 1
Make the crash a type error
In the first example, change
track("page-view")totrack(42). Predict: one new error (TS2345). The ReferenceError stays, because the type problem and the missing function are two separate bugs. - 2
Fix isolatedDeclarations
In the third example, add
: numberaftercomputed,parsed, andsize. Predict the Types panel: it should say No type errors. - 3
What do you give away?
Read
dist/index.d.tsagain. List what a user of the package can learn aboutCounterfrom it, and what they cannot (the private field's name and the body ofnext).
Code & diagrams
tsc is happy: you promised track exists. At runtime nobody defined it, so the program stops with a ReferenceError. declare never writes any JavaScript.
Console
(nothing was printed)Expected console output
start
Uncaught ReferenceError: track is not definedFour kinds of declaration: a value, an interface, a namespace of functions and a class with only signatures. We never call show, so nothing needs to exist at runtime. In a .d.ts file the word declare is how top-level values are written.
Console
(nothing was printed)Expected console output
functionThis example turns on isolatedDeclarations. limits and label are fine (a simple literal's type is obvious from one file). computed, parsed and size need a call or a name from elsewhere, so the type must be written. Add : number to each and the errors vanish.
Console
(nothing was printed)Expected console output
2 10 10 7 2$ cat src/main.ts
import { slugify, VERSION } from "./legacy.js";
console.log(slugify("Hello Big World"), VERSION);
console.log(slugify(42));
$ tsc -p .
src/main.ts(4,21): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
# a .d.ts that contains code:
src/bad.d.ts(1,26): error TS1039: Initializers are not allowed in ambient contexts.
src/bad.d.ts(2,21): error TS1183: An implementation cannot be declared in ambient contexts.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Put code in a .d.ts file
Write export const x: number = 5; in src/bad.d.ts.
Break #2
Export without a return type under isolatedDeclarations
Turn on "isolatedDeclarations": true and write export function makeId(prefix: string) { return prefix + "-1"; }.
Myth vs fact
Myth
A .d.ts file is only for libraries.
Fact
Apps use them too: to describe globals injected by a script or bundler, to type assets (import logo from "./logo.svg") and to type untyped packages.
Myth
declare makes the thing exist.
Fact
declare is a promise to the checker only. Nothing is written to the JavaScript, so a wrong promise becomes a runtime ReferenceError.
Myth
TypeScript always needs a .d.ts to use a .ts dependency.
Fact
Inside one project, TypeScript reads .ts sources directly. .d.ts files matter for published packages and for JavaScript.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A
.mtsfile produces.d.mtsand a.ctsfile produces.d.cts, so a dual package (Topic 11.2) can ship matching declarations for each format. A single.d.tsthat saysexport defaultcan be wrong for one of them. - ▸
isolatedDeclarationsis what lets fast tools write.d.tsfiles without running a full type checker. It does not make the checker itself faster; it removes the checker from the declaration step. In a monorepo with project references (Topic 12.4), this unblocks building packages in parallel. - ▸
Generated declarations must be able to write down every type they mention, and a few inferred types cannot be written. For example, a function that returns an anonymous class with a
privatemember fails witherror TS4094: Property 'secret' of exported anonymous class type may not be private or protected.(real, 7.0.2 withdeclarationon). A type that is merely not exported is fine:tsccopiesinterface Hidden { a: number }into the.d.tswithoutexport, so users get the shape but cannot import the name. Writing the return type yourself, or giving the class a name, resolves the TS4094 case.
Remember this
- 1
The problem this solves: JavaScript files carry no types, and types are erased from compiled TypeScript, so a library that ships only
.jswould be a black box to the checker. A.d.tsfile hands the checker the missing types. It may contain only declarations:interface,type, and statements that *describe* things that exist elsewhere, such asexport declare function slugify(text: string, sep?: string): string;. It can't contain function bodies or values (const x = 5;). If you try, tsc saysTS1039: Initializers are not allowed in ambient contextsorTS1183: An implementation cannot be declared in ambient contexts(real errors). Ambient means "already exists in the environment": the code is somewhere else. - 2
The keyword **
declare** is how you say "this exists at runtime; trust me, here is its type".declare const VERSION: string;,declare function track(event: string): void;,declare class Logger {}anddeclare namespace Analytics {}all create types and names for the checker only. They create nothing at runtime. If nobody really definestrack, the program crashes withReferenceError, and TypeScript can't warn you: you made a promise (the live example shows this). Inside a.d.tsfile,declareis added automatically in many places, but top-level values need it (orexport declare). - 3
Why do these files exist? Because packages are shipped as JavaScript, and types are erased. So a library author ships the
.jsfiles plus.d.tsfiles with the types, and thetypescondition inpackage.jsonpoints to them (Topic 11.2). When youimport { slugify } from "./legacy.js"and alegacy.d.tssits next tolegacy.js, TypeScript uses it: with the leaflet above,slugify(42)was rejected withTS2345(real run), while the JavaScript itself would have run happily and returned a wrong answer. - 4
You rarely write
.d.tsfiles by hand for your own code. You generate them: set"declaration": trueandtscwrites one.d.tsper.tsfile next to the.js. The output keepsexports, drops function bodies, writes inferred types out in full (makeUserbelow shows a return type nobody typed), turns#privatefields into a single#private;marker, and keepsas constliterals asreadonly.declarationMap: trueadds.d.ts.mapfiles so "go to definition" in an editor jumps to your.tssource instead of the.d.ts.emitDeclarationOnlywrites only the.d.tsfiles, when something else builds the JavaScript. - 5
Generating a
.d.tscan be slow, because for every export without a written type, the compiler must infer it, which can mean checking the whole program. **isolatedDeclarations** (since 5.5) is a rule that says: every exported thing must carry enough written types that its.d.tscan be produced by looking at one file alone. Then other tools (not justtsc) can write declarations in parallel, which speeds up big monorepos (one repository that holds many packages). The price: exported functions need return types, exported variables need annotations when the value isn't a simple literal. Violations are errors in the TS90xx range, for example TS9010 (variable needs a type), TS9012 (class property needs a type) and TS9007 or TS9013 (function return type). - 6
Declaration files are also how TypeScript itself knows the browser and ECMAScript.
lib.dom.d.tsdeclaresdocumentandHTMLElement,lib.es5.d.tsdeclaresArray,PartialandPick. They are ambient declarations: the objects come from the browser, the types from the file. You'll see more in Topic 11.4 and Topic 11.5.
Explain it without notes
What is a .d.ts file and what may it contain?
What does declare do, and what does it not do?
Why would you turn on declaration and declarationMap?
What problem does isolatedDeclarations solve and what does it ask of you?
Practice
Write the .d.ts file for this JavaScript module, accepting an optional second parameter: export function pad(text, width) { return text.padStart(width || 8); } and export const DEFAULT_WIDTH = 8;.
Under isolatedDeclarations, this line fails: export const total = [1, 2, 3].reduce((a, b) => a + b, 0);. Fix it in two ways and say which error you saw.
Trade-offs
- ↔
Writing return types on exported functions costs a few keystrokes, but the written type becomes a checked contract: if the body drifts, the error appears in the function, not in a caller far away. That is why many teams require them even without
isolatedDeclarations. - ↔
A hand-written
.d.tsfor a JavaScript library is fast to start, but it can silently drift from the real code. Prefer types generated from the source, or move the module to TypeScript.
Done when you can
I can say what a
.d.tsfile may and may not contain.I can explain that
declarecreates no runtime code.I can turn on
declarationanddeclarationMapand read the generated file.I can write a small
.d.tsfor a JavaScript function.I can say what
isolatedDeclarationsasks me to write, and why it speeds up builds.