Declaration Files
Types without implementation
Section titled “Types without implementation”A declaration file — a .d.ts — is a file that contains types only, no runtime code. It describes the shape of something that exists elsewhere: a JavaScript library, a global variable, a module. It is how TypeScript knows the types of code it did not compile.
You have used them constantly without noticing. When you import fs in Node and get autocomplete, that is a .d.ts. When document.querySelector is typed, that is lib.dom.d.ts. The types and the implementation are separate files.
declare and ambient declarations
Section titled “declare and ambient declarations”The declare keyword means “trust me, this exists at runtime — here is its type.” It introduces an ambient declaration: a type with no implementation attached.
declare const APP_VERSION: string; // injected by the bundler at build timedeclare function gtag(...args: any[]): void; // a global from a script tag
// now usable anywhere, fully typed, with no importconsole.log(APP_VERSION);gtag("event", "page_view");Without declare, writing const APP_VERSION: string would be a value declaration and the compiler would expect an initializer and emit code. declare says: this is purely a type promise; emit nothing.
Typing an untyped library
Section titled “Typing an untyped library”Say you install a small JavaScript library with no types. You can describe its shape yourself with a module declaration:
declare module "legacy-lib" { export function parse(input: string): { ok: boolean; value: number }; export const version: string;}Now import { parse } from "legacy-lib" is fully typed, even though the library ships plain JavaScript. You have supplied the missing contract by hand.
A real gotcha worth knowing before you hit it: declare module "x" { ... } only introduces a fresh ambient module when it appears in a script-scope file (no top-level import/export anywhere in that file) or a dedicated .d.ts. The moment the same file also has a real import/export, it becomes a module, and TypeScript reinterprets declare module "legacy-lib" { ... } as augmenting an existing module named "legacy-lib" rather than declaring a new one — which fails with TS2664: Invalid module name in augmentation, module 'legacy-lib' cannot be found (verified in scratch) if no real "legacy-lib" module exists to augment. This is exactly why library shims like the one above conventionally live in their own .d.ts file: putting one in the same file as your application’s regular imports silently changes what the same syntax means.
DefinitelyTyped and @types
Section titled “DefinitelyTyped and @types”Most of the time, someone has already written those declarations for you. DefinitelyTyped is a huge community repository of .d.ts files for thousands of JavaScript libraries, published under the @types/* scope:
npm install --save-dev @types/lodashWhen you import a package, tsc finds its @types/* companion in node_modules automatically, so installing @types/lodash instantly types import _ from "lodash". Many modern libraries ship their own types in the package (declared via the "types" field in their package.json), in which case you need no @types package at all.
Packages that contribute globals rather than modules — @types/node for process and Buffer, @types/jest for describe and expect — are a different story since TypeScript 6.0. The default types is now [], so they are no longer loaded just because they are installed. List them:
{ "compilerOptions": { "types": ["node", "jest"] } }("types": ["*"] restores the old load-everything behaviour.) The change stops a project from silently pulling in hundreds of unrelated @types/* packages from transitive dependencies, which was both slow and a source of “duplicate global” errors.
Reading a real .d.ts, and where it came from with no "types" field at all
Section titled “Reading a real .d.ts, and where it came from with no "types" field at all”escape-string-regexp (a small, real, widely-used package sitting in this course repo’s own node_modules) is a good one to read end to end, because its whole public surface is 16 lines:
// @skip-verify a real node_modules/escape-string-regexp/index.d.ts, quoted for reading, not for compiling standalone// (its JSDoc @example is normally its own fenced code block; shown here unfenced to avoid nesting ``` inside ```)/**Escape RegExp special characters.
You can also use this to escape a string that is inserted into the middle of a regex, for example, into a character class.
@example import escapeStringRegexp from 'escape-string-regexp';
const escapedString = escapeStringRegexp('How much $ for a 🦄?'); //=> 'How much \\$ for a 🦄\\?'
new RegExp(escapedString);*/export default function escapeStringRegexp(string: string): string;Ten things worth noticing: (1) a /** */ JSDoc block above a declaration shows up as editor hover documentation, exactly like a doc comment on your own code; (2) @example renders as a runnable-looking snippet in hover tooltips in most editors; (3) export default here declares the shape of a value the runtime provides, not a real function body — there is no { ... } after the signature; (4) the parameter string: string — a parameter named string shadowing the type string in its own annotation is legal, since parameter names and type names live in separate namespaces; (5) the return type string is written explicitly, exactly the discipline isolatedDeclarations requires; (6) this file has no imports and no other declarations — a .d.ts with a single default export is the minimal, complete shape for a single-function package; (7) this package’s package.json has no "types" field at all — no "types": "./index.d.ts" anywhere; (8) it does have "exports": "./index.js", pointing at the JS entry; (9) because there’s no explicit "types" field or exports "types" condition, tsc falls back to its implicit rule — look for a .d.ts file with the same base name next to the resolved JS file, and index.d.ts sitting beside index.js satisfies that automatically; (10) the package’s own "scripts": { "test": "xo && ava && tsd" } runs tsd — the exact type-testing tool this module’s index lesson describes — against this very file, which is why the types can be trusted without a human re-verifying them by hand.
lib files, and replacing them
Section titled “lib files, and replacing them”The built-in .d.ts files for the platform — lib.es2025.d.ts, lib.dom.d.ts — are selected by target and lib. Two recent changes: lib.dom.d.ts now contains everything that used to be split into dom.iterable and dom.asynciterable (those entries are empty stubs kept for old configs), and the DOM declarations carry MDN summaries so hover shows a one-line description of each API.
If the bundled lib.dom is wrong for you — too old, or you want the @types/web types — libReplacement: true (5.8) makes tsc look for an @typescript/lib-dom package in node_modules and use it instead. It is off by default since 6.0 because most projects never need it and the lookup costs time.
/// <reference types> is a per-file escape hatch around types: []
Section titled “/// <reference types> is a per-file escape hatch around types: []”The types compiler option controls what’s auto-loaded for the whole program. A single triple-slash directive at the top of one file is a separate, per-file mechanism that works even when types is [] for everything else:
// @skip-verify multi-file example -- this course's own repo has @types/node installed; a bare scratch dir does not, so this is shown as prose-verified rather than run through the standard harness/// <reference types="node" />console.log(process.platform); // pulled in by the directive above, even with "types": [] project-wideVerified in scratch: with "types": [] set explicitly (so nothing loads automatically) and @types/node installed, a file with no /// <reference types="node" /> directive fails on process with TS2591 (“Cannot find name ‘process’… Do you need to install type definitions for node?”); the identical file with that one directive compiles cleanly, in the same program, with the same tsconfig. types is the blunt, whole-project switch; /// <reference types="..."> is the scalpel for the one file that genuinely needs a global the rest of the program doesn’t.
skipLibCheck: a real trade-off, not just a speed switch
Section titled “skipLibCheck: a real trade-off, not just a speed switch”skipLibCheck skips type-checking inside .d.ts files — including ones your own dependencies ship, not just the built-in lib.*.d.ts files. That’s a real trade-off, not a free lunch, and it’s easy to prove: a dependency with a genuine bug in its own .d.ts — two conflicting declarations of the same property — compiles as an error without skipLibCheck, and silently merges to one arbitrary type with it on (verified in scratch, a real two-property-declaration conflict in a node_modules package):
$ tsc --noEmit --strict # skipLibCheck off (default false)node_modules/brokenlib/index.d.ts(6,3): error TS2717: Subsequent property declarations musthave the same type. Property 'retries' must be of type 'number', but here has type 'string'.
$ tsc --noEmit --strict --skipLibCheck # skipLibCheck on # (zero output -- the conflict is silently accepted)skipLibCheck is on by default in every config this course has shown so far (tsc --init’s own output includes it), and for good reason — checking every dependency’s declaration files on every build is real, wasted work when you trust the ecosystem’s types are basically sound. But “basically sound” is doing real work in that sentence: the flag doesn’t just skip slow checking, it skips checking that can catch a genuine bug shipped in a library’s own types, exactly like the one reproduced above.
declare is a type-only promise -- reassigning a declare const is still a real error
declare const APP_VERSION: string;declare function gtag(...args: unknown[]): void;
console.log(APP_VERSION.toUpperCase());gtag("event", "page_view");
// @ts-expect-error -- declare is a type-only promise; assigning to it is still a real errorAPP_VERSION = "2.0"; Click Check types: zero errors, which is the interesting result here, not a bug. declare const promises the checker that APP_VERSION exists at runtime as a string — it says nothing about how, which is exactly the point of an ambient declaration (a bundler define, an injected global, whatever supplies it in practice). The @ts-expect-error line consumes the one real error this file does have (reassigning a const), so a clean run here means both things are true at once: the ambient promise is honored everywhere it’s used, and the compiler still enforces the ordinary rules (const can’t be reassigned) on top of it.
Emitting your own declarations
Section titled “Emitting your own declarations”If you publish a library written in TypeScript, you want to ship .d.ts files so your consumers get types. Turn on emit for declarations:
{ "compilerOptions": { "declaration": true } }Now tsc produces a .d.ts next to each emitted .js, and you point package.json’s "types" field at the entry declaration. Your users import your library and get full type information — the same mechanism the whole ecosystem runs on.
Two options improve what gets emitted:
isolatedDeclarations(5.5) requires an explicit type on every export. In return,.d.tsfiles can be generated one file at a time without running the checker — in parallel, or by a bundler plugin — which is what large monorepos and tools likeesbuildneed to emit declarations fast.- Since 5.8 a computed property whose key is a plain identifier or a dotted name is preserved in declaration output instead of being widened to a generic index signature:
declare const propName: string;export const config = { [propName]: 1,};
declare const ns: { key: string };export const config2 = { [ns.key]: 2,};
// tsc --declaration emits:// export declare const config: { [propName]: number; };// export declare const config2: { [ns.key]: number; };// (pre-5.8 this widened both to { [x: string]: number })so consumers see the real shape instead of a bare index signature.
Mental Model
Section titled “Mental Model”Every mechanism in this lesson answers the same question at a different scope: where does the compiler get types for code it did not itself compile, and how wide is that promise? declare makes a promise about one name. declare module "x" { ... } makes one about a whole module — but only when the file it’s in isn’t already a module itself, otherwise the exact same syntax means something else entirely (augmentation). types makes a promise about every file in the program at once; /// <reference types="..."> narrows that same promise back down to one file. skipLibCheck is a promise in the other direction — not “trust this shape exists” but “trust this shape is correct without re-verifying it” — which is why, unlike the others, it can let a real bug through instead of just filling a gap.
Trade-offs
Section titled “Trade-offs”| Mechanism | Scope | Benefit | Cost |
|---|---|---|---|
declare const/declare function | One name | Types a global with zero runtime code | No enforcement that the real value matches — purely a promise |
declare module "x" { ... } in a dedicated .d.ts | One module | Types an untyped dependency fully | Only works as a fresh declaration outside module-scope files — the same syntax means “augment” inside one |
types: [...] | Whole program | One place to control every auto-loaded global package | All-or-nothing per package; can’t opt one file out |
/// <reference types="..."> | One file | Precise, file-scoped opt-in, works even with types: [] | Easy to forget, and invisible to anyone not reading that file’s top |
skipLibCheck | Every .d.ts in the program | Real, meaningful speed-up; the default for good reason | Can hide a genuine bug shipped in a dependency’s own types, not just save time |
Common Misconceptions
Section titled “Common Misconceptions”declare module "x" { ... }always creates a fresh ambient module. Only in a script-scope file (no top-levelimport/export) or a real.d.ts. The identical syntax in a file that already has real imports/exports is treated as augmenting an existing module named"x"— and fails withTS2664if no such module exists to augment.typesand/// <reference types="...">do the same job at different syntax.typesis a whole-program setting; the triple-slash directive is scoped to the one file it’s written in, and works precisely because it’s independent of the project-wide setting — it still pulls in a package’s types even whentypes: []would otherwise load nothing.skipLibCheckonly skips checking slow, irrelevant.d.tsfiles (the built-inlibfiles). It skips checking every.d.tsfile, including ones a real dependency ships with a real bug in them — proven above with a genuineTS2717that only appears with the flag off.- A
.d.tswith no"types"field in itspackage.jsonhas no types at all.escape-string-regexp(read end to end above) ships real types with no"types"field whatsoever —tscfindsindex.d.tsnext toindex.jsthrough an implicit sibling-file rule, no explicit field required.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a generated shim put
declare module "some-lib" { ... }in the same file as the application code that imports it (which already has otherimport/exportstatements)? That’s theTS2664trap from this lesson — the shim belongs in its own.d.ts. - Does a generated
tsconfig.jsonadd"skipLibCheck": truewithout anyone checking whether a real dependency’s declaration files have a real problem it’s now hiding? It’s the right default for most projects, but “add it because a.d.tserror appeared” without reading what the error was can hide the exact kind of bug this lesson reproduced. - Does generated code add a
/// <reference types="...">directive to “fix” a missing global, when the real fix is adding the package to the project-widetypesarray because every file needs it? A one-off directive for a genuinely one-off file is fine; scattering the same directive across many files is a signtypesshould just include it. - Is a described “the library has no types” claim actually checked against the real
package.json— a missing@types/*package doesn’t mean untyped if the library ships its own via an implicit sibling.d.ts, exactly like the real example read in this lesson?
🐛 Spot the bug
{"name": "internal-metrics-client","type": "module","main": "./dist/index.js"}Show the answer
This package.json has no "types" field, no "exports" field, and no sibling .d.ts next to dist/index.js (assume the build only emits .js). A consumer importing this package gets TS7016: Could not find a declaration file for module 'internal-metrics-client' — and the tempting “fix,” reached for reflexively, is skipLibCheck or a blanket // @ts-ignore on the import, neither of which addresses the real problem: this package genuinely ships no types anywhere. The real fixes are one of (a) the library turning on "declaration": true and pointing "types" at the emitted .d.ts, (b) a consumer-side declare module "internal-metrics-client" shim in its own .d.ts file (not mixed into a file with real imports, per this lesson’s TS2664 gotcha), or (c) checking DefinitelyTyped for a community @types/internal-metrics-client package, which won’t exist for an internal package. Reviewing this means recognizing TS7016 as “no types were found,” not “the types are wrong,” and picking the fix that matches which side of the boundary — publisher or consumer — actually owns the problem.