Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~22 min· topic 3 of 6

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.ts file with only types and declare statements. 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.ts for each .ts file.
declarationMap
Extra .d.ts.map files so the editor can jump from a .d.ts to the original .ts.
emitDeclarationOnly
Write the .d.ts files but not the .js files.
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.

src/index.tswhole filets
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.

terminalwhole filebash
$ tsc -p . ; ls dist
index.d.ts  index.d.ts.map  index.js

03Read 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.

dist/index.d.tswhole filets
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.map

04Hand-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.

src/legacy.d.tswhole filets
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.

src/index.tswhole filets
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. 1

    Make the crash a type error

    In the first example, change track("page-view") to track(42). Predict: one new error (TS2345). The ReferenceError stays, because the type problem and the missing function are two separate bugs.

  2. 2

    Fix isolatedDeclarations

    In the third example, add : number after computed, parsed, and size. Predict the Types panel: it should say No type errors.

  3. 3

    What do you give away?

    Read dist/index.d.ts again. List what a user of the package can learn about Counter from it, and what they cannot (the private field's name and the body of next).

Code & diagrams

declare promises something exists: it creates nothing

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 defined
The things you can declare

Four 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

function
isolatedDeclarations: what must be written down TypeScript 5.5+

This 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
Project: the legacy.d.ts import and the wrong call (real tsc 7.0.2)bash
$ 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.

terminal
$ tsc -p .
── what you'll see ──
src/bad.d.ts(1,26): error TS1039: Initializers are not allowed in ambient contexts.

Break #2

Export without a return type under isolatedDeclarations

Turn on "isolatedDeclarations": true and write export function makeId(prefix: string) { return prefix + "-1"; }.

terminal
$ tsc -p . (TypeScript 7.0.2)
── what you'll see ──
src/index.ts(2,10): error TS9013: Expression type can't be inferred with --isolatedDeclarations.

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 .mts file produces .d.mts and a .cts file produces .d.cts, so a dual package (Topic 11.2) can ship matching declarations for each format. A single .d.ts that says export default can be wrong for one of them.

  • ▸

    isolatedDeclarations is what lets fast tools write .d.ts files 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 private member fails with error TS4094: Property 'secret' of exported anonymous class type may not be private or protected. (real, 7.0.2 with declaration on). A type that is merely not exported is fine: tsc copies interface Hidden { a: number } into the .d.ts without export, 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. 1

    The problem this solves: JavaScript files carry no types, and types are erased from compiled TypeScript, so a library that ships only .js would be a black box to the checker. A .d.ts file hands the checker the missing types. It may contain only declarations: interface, type, and statements that *describe* things that exist elsewhere, such as export declare function slugify(text: string, sep?: string): string;. It can't contain function bodies or values (const x = 5;). If you try, tsc says TS1039: Initializers are not allowed in ambient contexts or TS1183: An implementation cannot be declared in ambient contexts (real errors). Ambient means "already exists in the environment": the code is somewhere else.

  2. 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 {} and declare namespace Analytics {} all create types and names for the checker only. They create nothing at runtime. If nobody really defines track, the program crashes with ReferenceError, and TypeScript can't warn you: you made a promise (the live example shows this). Inside a .d.ts file, declare is added automatically in many places, but top-level values need it (or export declare).

  3. 3

    Why do these files exist? Because packages are shipped as JavaScript, and types are erased. So a library author ships the .js files plus .d.ts files with the types, and the types condition in package.json points to them (Topic 11.2). When you import { slugify } from "./legacy.js" and a legacy.d.ts sits next to legacy.js, TypeScript uses it: with the leaflet above, slugify(42) was rejected with TS2345 (real run), while the JavaScript itself would have run happily and returned a wrong answer.

  4. 4

    You rarely write .d.ts files by hand for your own code. You generate them: set "declaration": true and tsc writes one .d.ts per .ts file next to the .js. The output keeps exports, drops function bodies, writes inferred types out in full (makeUser below shows a return type nobody typed), turns #private fields into a single #private; marker, and keeps as const literals as readonly. declarationMap: true adds .d.ts.map files so "go to definition" in an editor jumps to your .ts source instead of the .d.ts. emitDeclarationOnly writes only the .d.ts files, when something else builds the JavaScript.

  5. 5

    Generating a .d.ts can 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.ts can be produced by looking at one file alone. Then other tools (not just tsc) 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. 6

    Declaration files are also how TypeScript itself knows the browser and ECMAScript. lib.dom.d.ts declares document and HTMLElement, lib.es5.d.ts declares Array, Partial and Pick. 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

01

What is a .d.ts file and what may it contain?

02

What does declare do, and what does it not do?

03

Why would you turn on declaration and declarationMap?

04

What problem does isolatedDeclarations solve and what does it ask of you?

Practice

01

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;.

02

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.ts for 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.ts file may and may not contain.

  • I can explain that declare creates no runtime code.

  • I can turn on declaration and declarationMap and read the generated file.

  • I can write a small .d.ts for a JavaScript function.

  • I can say what isolatedDeclarations asks me to write, and why it speeds up builds.