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.
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.
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.
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.
Now the part TypeScript adds. Alongside values, an export list can carry types:
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.
import { fetchMenu } from "./api";A value — the code calls it, so the line stays as writtenimport type { MenuItem } from "./menu";Types only — the whole line is removedimport { TAX, type Order } from "./order";Mixed — Order is taken out, TAX staysWhy 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.
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.
.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:
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.
declare: the implementation is not here, but it will exist at run time. §04 is about that word.Covers: document · fetch · window — everything the browser provides.
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).Where a library's types come from
Three places, checked in order. The compiler stops at the first one that answers.
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.
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.
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.
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.What does source 1 look like? Open the package.json of any library written in TypeScript and look for these fields:
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.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.
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.
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.
The most common real need: something was attached to window and you want it typed. From inside a module, that requires declare global.
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.
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.
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:
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.
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:
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:
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.
Practice
Five tasks: watch module scope appear, watch a declaration file get generated, install @types once, and write a declaration by hand.
Quiz
Eleven questions on module scope, type-only imports, and where a library's types come from.
A TypeScript file has no top-level import and no top-level export. What is it, and where do its top-level declarations live?
What is inside a .d.ts file?
import type { Order } from "./order" — what does this line become in the emitted JavaScript?
Why write import type explicitly instead of leaving it to the compiler?
You import lodash and get ts(7016), "Could not find a declaration file for module 'lodash'". What should you do first?
What is the name of the community-maintained repository that the @types/* packages are published from?
What do you get after installing @types/node?
Which statements about declare are correct? (multiple)
Your tsconfig.json maps "@/*" to "./src/*" under paths, and tsc reports no errors. You run the emitted JavaScript with plain node. What happens?
What does "skipLibCheck": true skip?
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?
- A file with no top-level
importorexportis a script, and its top-level declarations go into the global scope, which is why two files can collide on a name:ts(2451). Oneexport {}gives the file module scope. - ESM imports are hoisted and can be read without running the program, which is what makes tree shaking possible;
requireruns where it is written.export defaultandmodule.exports =are different things, andesModuleInteropemits the__importDefaultwrapper that lets a default import read a CommonJS export. import typeandexport typemark a line as types only, so the line is removed and no module is loaded at run time.verbatimModuleSyntaxmakes the marking required:ts(1484)on import,ts(1205)on re-export.- A
.d.tsfile describes exports and emits nothing. It cannot hold an implementation:ts(1183). And it does not replace the module — a declaration with no matching.jspasses the type check and fails at run time. - Three sources for a library's types, checked in order: the package's own
types(or thetypescondition inexports), then@typesfrom DefinitelyTyped, then a declaration you write. All three empty meansts(7016). Keep the@typesmajor and minor version aligned with the library. declarestates a type and produces no code, so a wrong claim fails at run time.declare globalis how a module reaches the global scope (ts(2669)elsewhere). In a script filedeclare module "x"declares a module that has no types; in a module file it augments one that does, and augmenting an untyped module ists(2665).- Resolution happens at compile time only.
moduleResolutiondecides whetherexportsand file extensions are honoured, andpathsdoes not change the specifier in the output. Whatever runs your code needs its own matching configuration.