Lesson 30 · Module 5.1 — Tooling & ecosystem
declare and .d.ts: types with no runtimeYou open a library and find a file full of types but no function bodies — every
line starts with declare or just describes shapes. Coming from OO, it reads like a header
file or an interface-only module. That's exactly what it is. This lesson decodes the one keyword and the
one file extension that mean "the code exists at runtime; here is only its type."
declare means "ambient": this thing already exists at runtime — here is its
type, emit no JavaScript for it." A .d.ts file is a file made entirely
of such declarations: pure type information, zero runtime code. A .ts file holds real code
that compiles to .js; a .d.ts file compiles to nothing.
declare says to the compilerA normal declaration creates something. An ambient declaration only promises it is already there — supplied by the runtime, by another script, or by a JS library with no types of its own. The compiler trusts the promise, type-checks against it, and emits nothing:
// "There is a global called VERSION, of type string. Don't emit it; trust me."
declare const VERSION: string;
// "There is a function gtag(...). I won't give a body — the runtime provides it."
declare function gtag(command: string, ...args: unknown[]): void;
Notice there is no = and no { ... } body. That absence is the tell: an
ambient declaration is all type, no value. Try to give it a body and the compiler objects — that would be
real code, which is the one thing declare forbids.
.ts vs .d.ts: the file is the boundaryThe extension tells you which world the whole file lives in:
| file | contains | compiles to | you write it when… |
|---|---|---|---|
| .ts | real code and types | a .js file | you are writing the actual program |
| .d.ts | declarations only — no runtime code | nothing (it is the types) | you describe code that already runs |
Inside a .d.ts file the declare keyword is often implied — the file is ambient
by nature — so you'll see bare interface, type, and function
signatures. They look like the body-less methods of an OO interface, and they play the same role: a
contract with no implementation.
// math-utils.d.ts — describes a plain-JS library that ships no types
export function clamp(n: number, lo: number, hi: number): number;
export interface Range { min: number; max: number; }
You rarely hand-write .d.ts files. Two common origins:
tsc with declaration: true
emits a .d.ts alongside each .js, so consumers get types without your source.@types/* packages (DefinitelyTyped) are folders
of .d.ts files describing untyped JS libraries. Installing @types/node just adds
declarations; it adds no runtime code.So when you read one, picture it as the type-only shadow of some JavaScript that exists
elsewhere. The implementation is real and runs; the .d.ts is the part TypeScript reads.
Before the trap below makes sense, you need the rule that decides a file's scope. TypeScript sorts
every file into one of two kinds using a single test — does the file have a top-level
import or export?
top-level import/export? | the file is a… | its top-level names live in… |
|---|---|---|
| none | script | the global scope — one space shared by the whole project |
| at least one | module | that file's own scope — private unless you export it |
So at the top level there are really only two scopes to keep in mind: the single
global scope that every script and every ambient declaration shares, and a separate module
scope for each file that is a module. A name in the global scope is visible everywhere with no import — and can
collide with any other global of the same name. A name in a module scope is invisible until someone
imports it.
A .d.ts is ambient by nature, so unless it contains an import or export
it counts as a script — and everything in it lands in that one shared global scope. Adding a single
top-level line flips the whole file to a module:
// globals.d.ts — no import/export, so this file is a SCRIPT
interface User { id: string; } // global: visible everywhere, no import
// ...add this one top-level line and the file becomes a MODULE —
// now the User above is file-local, not global:
export {};
.d.tsIt is tempting to use a .d.ts as a handy place to keep your project's types — they become
available everywhere without importing anything. But that is the wrong tool. A .d.ts is meant
to describe code that already runs somewhere else (a JS library, a browser global). It is not where
the types for the code you are writing belong. Two reasons this matters:
.d.ts with no
import or export is a script, so every interface,
type, and declare in it lands in the shared global scope — visible everywhere with
no import. That sounds convenient, but names can now clash, and when you see User in some file
you cannot tell where it came from..d.ts produces no JavaScript,
and you normally do not write one by hand — tsc generates it for you. Keeping your real types
there cuts them off from the actual code, and "go to definition" and renames stop working well.Dropping interface User { … } into a hand-written .d.ts "so I can use it
everywhere" does work — and that is exactly the trap. You have made a global with no import trail. Your own
types belong in a normal .ts file that you export.
// AVOID — globals.d.ts has no import/export, so this leaks into EVERY file
interface User { id: string; name: string; } // now global; nothing imports it
// PREFER — models/user.ts is a real module: explicit, scoped, refactor-friendly
export interface User { id: string; name: string; }
// elsewhere: import type { User } from "./models/user";
So when is an interface or type inside a .d.ts fine? Only when
it is describing something that already exists at runtime — an untyped JS library (like the
math-utils.d.ts above, which describes someone else's code), a global the page sets up, or
a non-code import:
// Legit .d.ts work — every line describes something that already exists at runtime
declare global { interface Window { dataLayer: unknown[]; } } // a global the page injects
declare module "*.svg" { const src: string; export default src; } // non-code import
Rule of thumb: if the type is for code you are writing, put it in a
.ts file and export it. Only reach for a .d.ts to describe code that
already exists but does not come with its own types.
Recall, don’t re-read. (Answers reveal on click.)
declare const VERSION: string; emit into the output JavaScript?.d.ts file differs from a .ts file in that it holds….d.ts file yourself?interface User { … } in a globals.d.ts that has no import or export. What happens?The official handbook entry points are
TypeScript Handbook — Declaration Files
(why and how .d.ts exists) and
Modules — Declaration Files
(how a .d.ts describes a module). For the keyword itself, see
Declaration Files — By Example.
Best way to feel it: in the TS Playground, set
declaration: true and watch the .d.ts panel mirror your code with every body removed.
Staring at a body-less file and unsure if it's runtime code or just types? Paste a few lines and ask
“.ts or .d.ts — does this emit JavaScript?” Next lesson (5.2) moves from
describing modules to finding them: module vs moduleResolution.