Declaration files (.d.ts)
Type-only files. Why @types exists. When you have to write one yourself.
You install a package. The editor says Could not find a declaration file for module 'whichever'. Implicitly typed as any. You shrug and use it anyway, except now every property access on the package is invisible to the type-checker. That message is asking you a real question: where are this package's types? The answer is one of three places — and when none of them have an answer, you write a small file yourself.
This lesson is about that file format and where it shows up.
What a .d.ts file is
A .d.ts file is a TypeScript file that contains only type information. No runtime code. No function bodies. Just signatures — the shapes that describe what some JavaScript provides.
export function formatPrice(cents: number): string;
export const taxRate: number;
export interface Money {
amount: number;
currency: "USD" | "EUR";
}Read it as a contract. Anyone importing from this module gets a formatPrice function that takes a number and returns a string, a taxRate constant of type number, and a Money interface they can use in their own types. The runtime code lives elsewhere — usually in a sibling .js file with the matching shape.
The compiler treats .d.ts files differently:
- They are not emitted as output. The compiler reads them; it doesn't transform them.
- Function bodies are not allowed. Only signatures.
- They can use
declareto describe code from a non-TypeScript source.
The mental model: a .d.ts is the menu, a .js file is the kitchen. The menu describes what's available; the kitchen does the work.
Think of .d.ts files as type skins for JavaScript. The JS does the real work. The skin tells TypeScript what shape that work has.
Generating .d.ts with tsc -d
If you write a TypeScript library that other people will use, they need types for it. The compiler can produce a .d.ts for every source file automatically.
{
"compilerOptions": {
"declaration": true,
"outDir": "dist"
}
}declaration: true (the long form of the -d CLI flag) tells the compiler to emit a sibling .d.ts for every .ts it compiles. You point your package's package.json at the result:
{
"name": "money-utils",
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
}When someone installs your package, their editor reads dist/index.d.ts and knows the types of everything you exported. They import a function and get auto-complete on its arguments without doing anything special.
This is what most modern packages do. They ship JS plus declarations side by side, and consumers get types for free.
declaration: true and ship the dist/. What do consumers see?Why @types/foo exists
Many packages were written in JavaScript long before TypeScript was popular. They ship no types. The community-maintained DefinitelyTyped repo fills the gap by publishing companion type packages on npm under the @types namespace.
npm install lodash npm install --save-dev @types/lodash
Two installs. The first is the runtime code. The second is the type skin written by volunteers and published by the DefinitelyTyped maintainers.
The compiler knows to look in @types. When you import _ from 'lodash', the resolver tries node_modules/lodash first for built-in types, then falls back to node_modules/@types/lodash. That fallback is a built-in rule, no config needed.
A package that ships its own types does not need an @types companion. A package that doesn't, often does. The error message at the top of this lesson is the compiler asking you which of these is true.
error TS7016: Could not find a declaration file for module 'qrcode'. Try `npm i --save-dev @types/qrcode` if it exists, or add a new declaration (.d.ts) file containing `declare module 'qrcode';`
The error itself tells you the next two moves. Try @types/qrcode first. If that exists on npm, install it and the error goes away. If not, you write a small declaration yourself.
tinybeacon that has its own bundled tinybeacon.d.ts. You also npm install @types/tinybeacon — which doesn't exist on DefinitelyTyped, so it errors. What is the correct path forward?declare module — typing untyped packages
Sometimes a package has no types and no @types companion. You still want to use it. You write a one-line declaration that tells TypeScript "this module exists, treat it loosely."
declare module 'tinybeacon';
That is the minimum. It declares that import x from 'tinybeacon' is allowed, with the imported value typed as any. The error goes away. You lose type safety for that import, but you can use the package.
You can do better with a few minutes of effort. Look at the package's README, copy the function signatures into the declaration, and the rest of your code becomes type-safe against that surface.
declare module 'tinybeacon' {
export function track(event: string, data?: Record<string, unknown>): void;
export function flush(): Promise<void>;
}That is a hand-written @types-style shim. The compiler now knows tinybeacon exposes track and flush with those signatures. Hovering each one in the editor shows the types. Misuse becomes a compile error.
For files like shims.d.ts to be picked up, they need to be inside the include pattern of your tsconfig.json. A common convention is src/types/ with include: ["src"].
A bare declare module 'foo' with no body silently types every import from foo as any. Useful as a quick patch; expensive to leave forever. Treat it as a TODO.
Ambient declarations
declare is a keyword that tells the compiler "this thing exists at runtime, even if you can't see the implementation." It works for variables, functions, and modules.
declare const APP_VERSION: string; declare function gtag(...args: unknown[]): void;
These are ambient declarations. They tell TypeScript that there is a global APP_VERSION constant and a global gtag function — both already in scope at runtime, perhaps injected by a build-time replacement (a Vite define) or a third-party script tag.
Without the declarations, referring to APP_VERSION in your code is a type error. With them, the editor types it correctly and the build works.
The rule that makes a .d.ts "ambient": no top-level import or export. The moment you add an import, the file becomes a module and its declarations are no longer global. Keep ambient files import-free.
/* ambient — affects the global scope */
declare const APP_VERSION: string;
/* the moment you add this, the declarations above stop being global: */
import { Some } from './some';That distinction trips people up. If you need both ambient declarations and imports, split them across two files.
declare const FEATURE_FLAGS: Record<string, boolean> at the top of a .d.ts file along with several import statements. The editor now says FEATURE_FLAGS is undefined in your app code. What changed?import readme from './README.md'. The runtime works but the editor flags the .md import as having no declaration. Which fix is best?