TSer/09 · Modules and declaration files
CHAPTER 09 · import type, .d.ts, @types

Modules and declaration files

A module keeps its top-level names to itself. A declaration file describes what a module exports and contains none of the code. This chapter covers both: what makes a file a module, what an import actually carries, and where the types for a JavaScript library come from when the library has none of its own.

§01

Module scope, and what an import carries

You already know import and export. Two things are worth being exact about: which files are modules at all, and which imports survive compilation.

Start with the rule everything else rests on. A file is a module only if it has a top-level import or a top-level export. A file with neither is a script, and its top-level declarations go into the global scope that every script in the program shares. That is why two files can collide on a name they never shared deliberately.

scope.ts · the same const in two files
1// a.ts — no import, no export, so this file is a script
2const TAX = 0.06;
3
4// b.ts — also a script
5const TAX = 0.08;
6// error TS2451: Cannot redeclare block-scoped variable 'TAX'.
7// Both declarations landed in the same global scope.
8
9// b.ts, fixed — one export is enough to make it a module
10export {};
11const TAX = 0.08; // this TAX now belongs to b.ts alone
export {} exports nothing. Its only job is to give the file module scope. You will see it again in §04, where declare global requires it.

The file decides this, not tsconfig.json

The module option in tsconfig.json chooses the output format: esnext, commonjs, and so on. It does not decide whether a given file is a module. Only the file itself does that, by having a top-level import or export.

This matters most in .d.ts files, where it is easy to have neither. The same declare module block means two different things depending on which kind of file it sits in. §04 shows both.

Next: the two module systems you will meet. ES modules (ESM) use import and export. Their imports are hoisted — every imported module is loaded and run before the importing file's first statement — and the list of imports can be read without running any code. CommonJS (CJS) uses require, which is an ordinary function call and runs where it is written.

main.mjs · ESM
1console.log("main starts");
2import "./dep.mjs"; // dep.mjs logs "dep body runs"
3console.log("main ends");
4
5// output:
6// dep body runs
7// main starts
8// main ends
9// The import was hoisted. dep.mjs ran before
10// the first line of this file.
main.cjs · CommonJS
1console.log("main starts");
2require("./dep.cjs"); // dep.cjs logs "dep body runs"
3console.log("main ends");
4
5// output:
6// main starts
7// dep body runs
8// main ends
9// require ran at the point where it is
10// written, like any other function call.

Why static imports allow tree shaking

Because ESM import and export declarations must appear at the top level and their names are fixed, a bundler can work out which exports are used without running the program. Unused exports can then be left out of the output. That removal is called tree shaking.

require gives no such guarantee. It can sit inside an if, take a computed path, and return a different object each time. So a bundler generally has to keep the whole module.

export default and module.exports = are not the same thing

export default x creates one named export called default on an ES module. module.exports = x replaces the whole exports object of a CommonJS module. So a CommonJS module that assigns a function to module.exports has no default property for an ESM default import to read.

esModuleInterop is what bridges the two. It makes the compiler emit a small helper, __importDefault, which wraps a non-ESM export as { default: … } so the default import finds something. It also changes the type rules to match: without the flag, the default import is rejected outright.

interop.ts · a default import from a CommonJS package
1// legacy-cjs/index.js module.exports = function stir(times) { … }
2// legacy-cjs/index.d.ts declare function stir(times: number): number;
3// export = stir;
4
5import stir from "legacy-cjs";
6
7// Without esModuleInterop:
8// error TS1259: Module '"…/legacy-cjs/index"' can only be
9// default-imported using the 'esModuleInterop' flag
10
11// With esModuleInterop, the emitted CommonJS is:
12// const legacy_cjs_1 = __importDefault(require("legacy-cjs"));
13// console.log((0, legacy_cjs_1.default)(3));
14// __importDefault wraps a non-ESM export in { default: … }.

Now the part TypeScript adds. Alongside values, an export list can carry types:

order.ts + shop.ts · values and types on the same list
1// ---- order.ts ----
2export interface Order { // a type can be exported too
3 id: string;
4 size: "small" | "medium" | "large";
5 total: number;
6}
7export const TAX = 0.06; // a named export, and a value
8export default function createOrder(): Order {
9 return { id: "MT-1", size: "medium", total: 15 };
10} // the default export: one per file
11
12// ---- shop.ts ----
13import createOrder, { TAX, type Order } from "./order";
14
15const o: Order = createOrder(); // Order is used as a type
16const withTax = o.total * (1 + TAX); // TAX is used as a value

Types are erased when the code is compiled. So what happens to the import line that brought a type in? Switch the panel below from source to output.

What the compiler does to each import
import { fetchMenu } from "./api";A value — the code calls it, so the line stays as written
import type { MenuItem } from "./menu";Types only — the whole line is removed
import { TAX, type Order } from "./order";Mixed — Order is taken out, TAX stays
Three imports, two kinds of thing: values, which the code needs while it runs, and types, which exist only during compilation. Switch to Output .js to see what happens to each one.

Why you should mark type-only imports yourself

tsc sees the whole project, so it can work out on its own that Order is only a type and that the import can go. Vite, esbuild, and SWC transpile one file at a time. They cannot tell whether Order is a value or a type, and dropping the wrong import breaks the program at run time.

verbatimModuleSyntax (TypeScript 5.0) removes the guesswork: an import or export statement is emitted exactly as written, and a type imported without type is reported as ts(1484), "'Order' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled." Re-exporting a type without export type gives the matching ts(1205).

There is a second reason, independent of tooling. A .js module can run code when it is loaded. A type-only import that survives into the output would load that module at run time for no benefit at all.

the three type-only forms
1// three separate forms, shown together for comparison
2import type { Order } from "./order"; // the line carries types only
3import { TAX, type Order } from "./order"; // one value and one type
4export type { Order } from "./order"; // re-export, types only
A useful habit: if a name appears only in type positions, mark it type when you import it. Editor auto-import does this by default now.

Circular imports are allowed, but a binding can be unset

Two ES modules may import each other. The specification allows it and the type checker reports nothing. What can still fail is the order: the modules run one after another, and if one reads a binding from the other before that module has reached the line that initialises it, the read throws ReferenceError.

Reading such a binding later — inside a function that is called after both modules have finished — is fine. The practical fix is to move the shared declaration into a third module that both import.

a.js + b.js · legal, and still able to fail
1// a.js
2import { B } from "./b.js";
3export const A = "A";
4
5// b.js
6import { A } from "./a.js";
7export const B = "B";
8console.log(A);
9// ReferenceError: Cannot access 'A' before initialization
10// a.js started first and had not reached its own
11// export line yet when b.js read the binding.
12// The type checker reports nothing here.
§02

.d.ts: shapes only, no implementation

A declaration file lists everything a module exports and how each export is shaped. It emits nothing, and it cannot contain code.

A declaration file (.d.ts) describes the public shape of a module: every export, with its type, and nothing else. You do not have to write one by hand. The compiler generates one from your source when you ask it to:

order.ts · the source
1export const TAX = 0.06;
2
3export interface Order {
4 id: string;
5 total: number;
6}
7
8export function createOrder(
9 items: string[],
10): { id: string; total: number } {
11 const total =
12 items.length * 15 * (1 + TAX);
13 return {
14 id: "MT-" + Date.now(),
15 total,
16 };
17}
order.d.ts · generated by tsc
1export declare const TAX = 0.06;
2export interface Order {
3 id: string;
4 total: number;
5}
6export declare function createOrder(items: string[]): {
7 id: string;
8 total: number;
9};
10
11// No function body anywhere: only shapes.
12// The interface came across unchanged,
13// because it was already types only.
14// Produced by:
15// npx tsc order.ts --declaration

A declaration file cannot hold code

Everything in a .d.ts is an ambient declaration: a statement about something that exists elsewhere. Write a function body in one and you get error TS1183: An implementation cannot be declared in ambient contexts.

This is also why a .d.ts can describe a library that was never written in TypeScript. Describing a shape does not require touching the code.

The built-ins come from declaration files too

Where do the types for document, fetch, and Promise come from? The same place: .d.ts files. TypeScript ships a set of them. lib.dom.d.ts covers what the browser provides. Files such as lib.es2022.d.ts cover the JavaScript language itself. The lib option in tsconfig.json decides which ones are loaded.

In your editor, hold Ctrl or Cmd and click fetch. You land inside lib.dom.d.ts. Seeing it once is enough to make "built-in" stop feeling like a special case.

lib.dom.d.ts (excerpt) · where document and fetch come from
1declare var document: Document;
2
3declare function fetch(
4 input: RequestInfo | URL,
5 init?: RequestInit,
6): Promise<Response>;
Note the word declare: the implementation is not here, but it will exist at run time. §04 is about that word.
The declaration files TypeScript ships with · pick one to see what it covers
In the default lib set

Covers: document · fetch · windoweverything the browser provides.

If you do not set lib, TypeScript picks a default set from target, and that default includes the DOM. Writing lib yourself replaces the default rather than adding to it. A Node-only project that sets "lib": ["es2022"] therefore has no DOM, and document is reported as ts(2584).
§03

Where a library's types come from

Three places, checked in order. The compiler stops at the first one that answers.

SOURCE 1
Bundled with the package

A library written in TypeScript builds with --declaration and points at the result with the types field in package.json, or a types condition inside exports. Install it and the types are there.

SOURCE 2
DefinitelyTyped

A community repository of declarations for libraries that ship none, published as @types/* packages: npm i -D @types/lodash. Nearly every established JavaScript library is covered.

SOURCE 3
✍️ You write it

Neither of the first two has anything? declare module in your own project. §05 builds one up step by step for a library with no types at all.

How the compiler finds a type declaration: three places, in order
🧭tscneeds a type for debounce
📦node_modules/lodash1 · types in the package?
📚node_modules/@types2 · community types?
🗂️your project3 · your own declaration
You write import { debounce } from "lodash". The compiler now needs to know the type of debounce, so it starts looking for a declaration. It checks three places, in order, and stops at the first one that answers.
1 / 6

What does source 1 look like? Open the package.json of any library written in TypeScript and look for these fields:

node_modules/boba-ui/package.json · pointing at the types
1{
2 "name": "boba-ui",
3 "version": "3.0.0",
4 "main": "./dist/index.js",
5 "types": "./dist/index.d.ts",
6 "exports": {
7 ".": {
8 "types": "./dist/index.d.ts",
9 "default": "./dist/index.js"
10 }
11 }
12}
Both forms appear in the wild. The top-level types field is the older one. The types condition inside exports is read by the newer resolution modes and can differ per subpath, which is why a package can offer different types for its ESM and CommonJS entry points.
source 2 · two installs you will do often
1# community declarations for lodash
2# -D because types are only needed while compiling
3npm i -D @types/lodash
4
5# declarations for Node's built-in APIs: fs, path, process…
6npm i -D @types/node
The naming rule is fixed: the declarations for npm package xxx are published as @types/xxx. @types/node covers Node's own APIs — without it, a Node project cannot even import fs.

@types versions drift away from the library

Declarations in DefinitelyTyped are written by volunteers, so they can lag behind the library. Install a new major version of lodash while @types/lodash stays on the old one, and the types will describe behavior the library no longer has.

The convention is that the major and minor version of the @types package match the library. When a type looks wrong, compare those two version numbers first.

Resolution is a compile-time answer only

Finding a declaration file is module resolution, and moduleResolution selects the rules. node10 is the legacy Node algorithm: it walks up through node_modules, reads main and types, and ignores the exports field. node16 and nodenext follow what modern Node actually does: they honour exports, and in an ESM file they require the file extension in a relative import (leaving it out is ts(2835)). bundler matches what bundlers do: it honours exports but allows extensionless imports. None of these is correct everywhere. The right one is the one that matches whatever loads your code.

paths is narrower still. It is a compile-time mapping only. tsc does not rewrite the specifier, so import { TAX } from "@/tax" is still written that way in the output. Node then reports Cannot find package '@/tax'. Next.js, Vite, and webpack read these paths or have an equivalent alias option, which is why aliases work in an app and break the first time you run plain node on the output.

tsconfig.json · the options that decide how a module is found
1{
2 "compilerOptions": {
3 "module": "esnext",
4 "moduleResolution": "bundler",
5 "paths": { "@/*": ["./*"] }
6 }
7}
This project uses bundler because Next.js loads the code. A library published to npm usually wants nodenext, so that the declarations it emits match what Node will do with them.

What skipLibCheck actually skips

skipLibCheck: true skips type checking inside .d.ts files, including the checks between them. Your own calls into those libraries are still checked in full.

It buys faster compilation and avoids errors you cannot fix, such as two @types packages declaring the same global with different types. The cost is that a genuine mistake inside a declaration file goes unreported.

§04

declare: stating that something exists at run time

declare creates nothing. It tells the compiler the type of something the compiler cannot see for itself.

Some things really are present at run time but invisible to the compiler: a global constant injected by the build tool, a function loaded by a <script> tag, an npm package with no types. declare is how you state their types: it exists, this is its shape, take my word for it.

globals.d.ts · three things you can declare
1// a global constant injected by the build tool (Vite's define, for example)
2declare const BUILD_TIME: string;
3
4// a global function loaded by a <script> tag, such as an analytics script
5declare function gtag(
6 command: string,
7 ...args: unknown[]
8): void;
9
10// a whole module that ships no types of its own
11declare module "legacy-lib" {
12 export function stir(times: number): void;
13}

The most common real need: something was attached to window and you want it typed. From inside a module, that requires declare global.

window-config.d.ts · adding a field to window
1// One export makes this file a module. declare global needs that.
2export {};
3
4declare global {
5 interface Window {
6 __SHOP_CONFIG__: { city: string; vip: boolean };
7 }
8}
9
10// From then on, in any file:
11// window.__SHOP_CONFIG__.city typed, with completion
Why the export {}? declare global is only allowed inside a module, and a file with no top-level import or export is a script. Put it in a script and you get error TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module declarations.

Two jobs, one keyword: declaring a module vs augmenting one

declare module "x" in a script file — no top-level import or export — is an ambient module declaration. It says: the module "x" exists and here are its types. Use it for a package that has no types of its own.

The same block in a module file is a module augmentation. It says: the module "x" already has types, add these to them. Use it to extend a typed library, for example to add a field to a theme interface.

Getting this backwards produces a confusing error. Put export {} in a file whose declare module was meant to describe an untyped package, and the compiler treats it as an augmentation with nothing to augment: error TS2665: Invalid module name in augmentation. Module 'boba-sdk' resolves to an untyped module …, which cannot be augmented.

boba-ui-theme.d.ts · augmenting a library that already has types
1// boba-ui ships its own types, including: interface Theme { accent: string }
2// This file has a top-level import, so it is a module.
3// Inside a module, declare module means "add to that module's types".
4import "boba-ui";
5
6declare module "boba-ui" {
7 interface Theme {
8 radius: number; // added to the library's own Theme
9 }
10}
11
12// render({ accent: "#0f0", radius: 8 }) now accepted
13// Without the augmentation:
14// error TS2353: Object literal may only specify known
15// properties, and 'radius' does not exist in type 'Theme'.

A declaration is a claim, and claims can be wrong

However carefully you write it, declare does not add anything to the run time. Declare gtag but never load the analytics script, and the program still fails with gtag is not defined.

When the type check passes and the program fails immediately, check three things: was the script actually loaded, does the package really export that name, and is the name spelled correctly?

A note on namespace

You will see declare namespace in older declaration files. namespace predates ES modules: it was TypeScript's own way of grouping names before the language had modules of its own. It still works, and it is still the natural way to describe a library that puts everything on one global object.

For new code, modules are the recommendation. A module already gives you a private scope and an explicit list of exports, which is what namespace was for.

§05

Practice: writing declarations for an untyped library

From one line that makes it compile to a declaration worth keeping. You close the any-shaped hole a bit at a time.

The setup: a project needs an old (imaginary) JavaScript package, boba-sdk. It has no types, and no @types/boba-sdk exists either. The first import fails:

shop.ts · the first import
1import { fetchMenu, order } from "boba-sdk";
2// error TS7016: Could not find a declaration file for
3// module 'boba-sdk'.
4// '…/node_modules/boba-sdk/index.js' implicitly has
5// an 'any' type.
6// Try `npm i --save-dev @types/boba-sdk` if it exists
7// or add a new declaration (.d.ts) file containing
8// `declare module 'boba-sdk';`

Step one: make it compile. Create types/boba-sdk.d.ts. The location does not matter as long as it is inside the include range in tsconfig.json.

types/boba-sdk.d.ts · v0
1declare module "boba-sdk";
2// The error is gone, and so is every type. The whole
3// module is any now, so nothing about it is checked.
4// This is a first step, not a finished job.
This short form declares that the module exists and says nothing about its contents, so every import from it is any. Keep this file a script: no top-level export, or the block becomes an augmentation and fails with ts(2665).

Step two: describe the parts you use. You do not have to cover the whole library. Give a shape to what you actually imported:

types/boba-sdk.d.ts · v1
1declare module "boba-sdk" {
2 export interface MenuItem {
3 name: string;
4 price: number;
5 soldOut?: boolean;
6 }
7
8 export function fetchMenu(shopId: string): Promise<MenuItem[]>;
9
10 export function order(
11 item: MenuItem,
12 size: "small" | "medium" | "large",
13 ): Promise<string>; // resolves to the order id
14}
From here on, fetchMenu has a real return type and passing the wrong size to order is an error. That is the line between an unchecked import and a checked one.

Step three: fill in the rest. The boba-sdk documentation also mentions a default-exported client and an event callback, so add those too:

types/boba-sdk.d.ts · v2
1declare module "boba-sdk" {
2 export type Size = "small" | "medium" | "large";
3
4 export interface MenuItem {
5 name: string;
6 price: number;
7 soldOut?: boolean;
8 }
9
10 export interface BobaClient {
11 fetchMenu(shopId: string): Promise<MenuItem[]>;
12 order(item: MenuItem, size: Size): Promise<string>;
13 on(event: "ready" | "error", cb: (msg: string) => void): void;
14 }
15
16 export default function createClient(key: string): BobaClient;
17}

How much is enough?

Describe what you use. The any-shaped hole gets smaller each time you come back to it, and nobody expects a complete declaration on the first pass.

If you do end up writing a complete and reliable one, send it to DefinitelyTyped. Once @types/boba-sdk is published, the next person to use this library does not have to do any of this.

A declaration file is not a replacement for the module

A .d.ts contains no code. If a project has helpers.d.ts but no helpers.js, then import { helper } from "./helpers" passes the type check and fails at run time with Cannot find module.

The compiler reads declarations. The run time looks for real files. Both have to exist.

§06

Practice

Five tasks: watch module scope appear, watch a declaration file get generated, install @types once, and write a declaration by hand.

§07

Quiz

Eleven questions on module scope, type-only imports, and where a library's types come from.

QUESTION 01 / 11

A TypeScript file has no top-level import and no top-level export. What is it, and where do its top-level declarations live?

QUESTION 02 / 11

What is inside a .d.ts file?

QUESTION 03 / 11

import type { Order } from "./order" — what does this line become in the emitted JavaScript?

QUESTION 04 / 11

Why write import type explicitly instead of leaving it to the compiler?

QUESTION 05 / 11

You import lodash and get ts(7016), "Could not find a declaration file for module 'lodash'". What should you do first?

QUESTION 06 / 11

What is the name of the community-maintained repository that the @types/* packages are published from?

QUESTION 07 / 11

What do you get after installing @types/node?

QUESTION 08 / 11

Which statements about declare are correct? (multiple)

QUESTION 09 / 11

Your tsconfig.json maps "@/*" to "./src/*" under paths, and tsc reports no errors. You run the emitted JavaScript with plain node. What happens?

QUESTION 10 / 11

What does "skipLibCheck": true skip?

QUESTION 11 / 11

A project has helpers.d.ts but no matching helpers.js. You write import { helper } from "./helpers" and run the output with Node. What happens?

What to take away from this chapter
  • A file with no top-level import or export is a script, and its top-level declarations go into the global scope, which is why two files can collide on a name: ts(2451). One export {} gives the file module scope.
  • ESM imports are hoisted and can be read without running the program, which is what makes tree shaking possible; require runs where it is written. export default and module.exports = are different things, and esModuleInterop emits the __importDefault wrapper that lets a default import read a CommonJS export.
  • import type and export type mark a line as types only, so the line is removed and no module is loaded at run time. verbatimModuleSyntax makes the marking required: ts(1484) on import, ts(1205) on re-export.
  • A .d.ts file describes exports and emits nothing. It cannot hold an implementation: ts(1183). And it does not replace the module — a declaration with no matching .js passes the type check and fails at run time.
  • Three sources for a library's types, checked in order: the package's own types (or the types condition in exports), then @types from DefinitelyTyped, then a declaration you write. All three empty means ts(7016). Keep the @types major and minor version aligned with the library.
  • declare states a type and produces no code, so a wrong claim fails at run time. declare global is how a module reaches the global scope (ts(2669) elsewhere). In a script file declare module "x" declares a module that has no types; in a module file it augments one that does, and augmenting an untyped module is ts(2665).
  • Resolution happens at compile time only. moduleResolution decides whether exports and file extensions are honoured, and paths does not change the specifier in the output. Whatever runs your code needs its own matching configuration.