Topic 11.1
Modules in TypeScript
In one line
A module is a file that has its own import or export, so its names stay private until you export them. TypeScript checks imports and exports the same way JavaScript runs them, with extra rules (import type, verbatimModuleSyntax, isolatedModules) that keep type-only code from leaking into the output.
Think of it like this
Each classroom has its own whiteboard. What the teacher writes in room 3 stays in room 3 unless she carries a sheet of paper (an export) to room 5, where somebody takes it in (an import). A file in JavaScript is a room with its own whiteboard: names declared inside a module are private to it until you export them. Without modules, every name would live on one giant shared wall and two people would constantly overwrite each other.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Module
- A file with its own top-level
importorexport. Its names are private unless exported. - Script
- A file with no
importorexport. Its top-level names go into the global scope. - Export / import
- Export shares a name from a file; import brings a shared name into another file.
- Default export
- The one main thing a file shares, imported without curly braces.
- ESM
- ECMAScript modules: the official JavaScript
import/exportsystem, used by browsers and modern Node.js. - CommonJS
- Node's older system:
require()andmodule.exports. - import type
- An import that only brings a type. It is always deleted from the JavaScript.
- Type erasure
- Removing all type syntax so only plain JavaScript is left.
- verbatimModuleSyntax
- A setting that keeps imports and exports exactly as written, unless they say
type. - isolatedModules
- A setting that bans code that can only be compiled by looking at other files, so one-file tools work.
Step by step
01Two files, one export, one import
shapes.ts shares one type and one function. main.ts uses both. This is the whole idea of modules.
The .js in "./shapes.js" is not a typo. When Node runs ES modules the import must name the file that will exist at runtime, which is the .js file the compiler writes. Topic 11.2 explains why.
export interface Circle {
radius: number;
}
export function area(c: Circle): number {
return Math.PI * c.radius ** 2;
}02Import a type without the word type
This project has verbatimModuleSyntax on. Circle is only a type, so a plain import { area, Circle } is ambiguous: should the JavaScript keep the Circle import or not? There is nothing named Circle at runtime, so keeping it would crash.
TypeScript refuses to guess and reports TS1484 (real output from tsc 7.0.2).
import { area, Circle } from "./shapes.js";
const c: Circle = { radius: 2 };
console.log(area(c).toFixed(2));
// src/main.ts(1,16): error TS1484: 'Circle' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.03Say what is a type
Add type in front of the name. Now the intention is written down: keep area, delete Circle.
Look at the output file. The Circle import and the annotation are gone, and nothing else changed. That's what *verbatim* means.
// source: import { area, type Circle } from "./shapes.js";
// output (dist/main.js), written by tsc:
import { area } from "./shapes.js";
const c = { radius: 2 };
console.log(area(c).toFixed(2));
// node dist/main.js prints: 12.5704Re-exporting a type needs export type
A file that only forwards things is a barrel (index.ts that re-exports). Re-exporting an interface has the same problem as importing one, and the same fix. With isolatedModules on, the message says so (real run; with verbatimModuleSyntax it is the same error, TS1205, naming that flag).
export { Circle } from "./shapes.js";
// src/reexp.ts(1,10): error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.
// fix:
export type { Circle } from "./shapes.js";05Which files are modules?
A file without import/export is a script under the default "auto" detection, and its names become global. "force" removes the surprise. The picture shows what the compiler asks about each file.
Try it yourself
- 1
Predict, then check
In the first example, change
export type Id = string | number;totype Id = string | number;(noexport). Nothing in this file usesId, so predict: does anything break? (No. A file only exports what it says, and the rest is private.) - 2
Make the script a module
In the second example, add a line
export {};at the end. Predict the Types panel first. TheCannot redeclareerror disappears, because the file is now a module andnameis no longer global. - 3
Read the message
In the third example, read both error codes aloud and say which one is about *exporting* and which about *importing*. Then replace the first line with
export default 5;: only one error goes away.
Code & diagrams
This file is a module (it has exports), so helper is private to it. The live editor can't import from another file, but this is every export form: named, type-only, default and renamed. Try deleting every export: the file is still a module here because the editor forces module detection.
Console
(nothing was printed)Expected console output
(0, 0)
stringThis example turns off the editor's forced module detection (moduleDetection: auto, TypeScript's own default). The file has no import or export, so it is a script, and its const name lands in the global scope next to the DOM's window.name. Add export {}; on a new line and the error goes away.
Console
(nothing was printed)Expected console output
Ashaexport = and import x = require() belong to CommonJS. In a project that outputs ES modules, tsc says so, and points at export default and import … from.
Console
(nothing was printed)The tiny project used in the walkthrough. rootDir and outDir are written out because TypeScript 6 and 7 no longer guess the output layout.
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"strict": true,
"verbatimModuleSyntax": true,
"rootDir": "src",
"outDir": "dist",
"types": []
},
"include": ["src"]
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Import a type like a value
In src/main.ts of the project, write import { area, Circle } from "./shapes.js"; (no type) with verbatimModuleSyntax on.
Break #2
Re-export an interface
Add src/reexp.ts containing export { Circle } from "./shapes.js"; with isolatedModules on.
Myth vs fact
Myth
import type makes the program smaller or faster.
Fact
It only makes the intent explicit and safe. A plain import of a type is already erased by default; the keyword matters for one-file tools, for verbatimModuleSyntax, and to avoid circular-import surprises.
Myth
A file is a module because it ends in .ts.
Fact
A file is a module only if it has a top-level import or export (or moduleDetection is forced). Otherwise its names are global.
Myth
export default is better than named exports.
Fact
Default exports can be imported under any name, which hurts search and rename. Many style guides prefer named exports. TypeScript supports both.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
verbatimModuleSyntaxreplaces two older flags,importsNotUsedAsValuesandpreserveValueImports, and (mostly) removes the need forisolatedModules. The two old flags are removed: TypeScript 6.0 reportserror TS5102: Option 'importsNotUsedAsValues' has been removed. Please remove it from your configuration. Use 'verbatimModuleSyntax' instead.(real 6.0.3 output), and 7.0 no longer knows them at all (error TS5023: Unknown compiler option 'importsNotUsedAsValues'., real 7.0.2 output). If you see them in an old tsconfig, replace them withverbatimModuleSyntax. - ▸
With
verbatimModuleSyntaxon and a CommonJS output, plainimport/exportsyntax in a CommonJS file is an error (TS1287 for a top-level export, TS1295 for an ES import): the flag forbids rewriting your syntax. You then writeimport x = require()or switch the package to ESM. This is another reason to move to ESM. - ▸
Type-only imports also help against import cycles: a type-only import is erased, so it can't create a runtime cycle between two files that only need each other's types.
Remember this
- 1
The problem this solves: when all your code lives in one file, or all files share one set of names, a change in one place breaks another. Modules let each file keep its own names and share only what it chooses. A module is a file that contains a top-level
importorexport. You writeexport function area() {}(orexport const,export class,export interface,export type) to share something, andimport { area } from "./shapes.js"in another file to use it. There is also one default export per file (export default class Shape {}), imported without braces (import Shape from "./shape.js"). TypeScript uses exactly the JavaScript (ECMAScript, or ESM) syntax; it only adds thatinterfaceandtypecan be exported and imported too. - 2
Types are erased before the code runs (Topic 0.2). So an
importof something that is only a type has nothing to import at runtime. (Recall from Topic 0.2: the compiler checks types, then deletes them.) TypeScript solves this with **import type** (since 3.8):import type { Circle } from "./shapes.js"is removed completely from the JavaScript, always. You can also mark single names:import { area, type Circle } from "./shapes.js"keeps the import ofareaand dropsCircle. The matching form for sharing isexport type { Circle }(andexport type { Circle } from "./shapes.js"to re-export). - 3
**
verbatimModuleSyntax** (since 5.0) makes the rule simple and visible: *what you write is what you get.* An import or export without the wordtypeis kept in the JavaScript exactly as written; one withtypeis deleted. If you import a name that is only a type without writingtype, you get TS1484, because TypeScript would otherwise have to guess whether to keep the import. It's the setting thattsc --initwrites, and the one this site's live editor uses. - 4
**
isolatedModulesis the older cousin. Tools like esbuild, SWC, Babel and Vite compile one file at a time**, without reading the other files, so they can't know whetherCircleis a type or a value.isolatedModulesmakes TypeScript refuse code that only works if it can look at other files. The classic case isexport { Circle } from "./shapes.js": ifCircleis an interface, the one-file tool can't know to erase it. TypeScript reports TS1205 and asks forexport type.verbatimModuleSyntaxalready implies the same safety, so with it on you rarely need to think aboutisolatedModules. - 5
**
moduleDetection** decides which files are modules. By default ("auto") a file is a module only if it has animport/export(or, in some setups,import.meta); any other file is a script, and its top-level names go into the global scope (one shared space that every script can see). That's why two scripts that both sayconst name = "x"clash, and whynameclashes with the browser's ownwindow.name. Setting"moduleDetection": "force"treats every non-declaration file as a module, which is the sane choice and whattsc --initwrites. A loneexport {}also turns a file into a module. - 6
Two older forms still exist and are errors in an ES module project:
export = valueandimport x = require("x"). They are CommonJS-era syntax. In a file that builds to ESM, TypeScript reports TS1203 and TS1202 and tells you to useexport default/import … from. Use them only if you really output CommonJS (module: "commonjs"), and in new code you almost never do. Which files are ESM and which CommonJS is decided bymodule, and by thetypefield inpackage.json: Topic 11.2.
Explain it without notes
What is the difference between import type { A } and import { type A }?
What does verbatimModuleSyntax do and why was it added?
Why does isolatedModules exist?
How does TypeScript decide whether a file is a module or a script?
Practice
A file util.ts has export interface Options { dry: boolean } and export function run(o: Options) {}. Write the import for another file with verbatimModuleSyntax on, using type correctly in two different ways.
This file declares const total = 10; and a second script file does too. Both have no imports or exports. What error do you get, and what is the smallest fix that does not rename anything?
Trade-offs
- ↔
verbatimModuleSyntaxmakes you writetypein more places, in return for output you can predict and tools that never guess. Teams almost always accept the trade. - ↔
A big barrel file (
index.tsthat re-exports everything) makes imports short, but can load far more code than needed and slows editors and bundlers. Prefer importing from the file that owns the thing in hot paths.
Done when you can
I can write a named export, a default export and a type-only export.
I can explain why
import { Circle }fails underverbatimModuleSyntaxand how to fix it.I can say what TS1205 and TS1484 mean.
I can explain the difference between a module and a script, and what
moduleDetectiondoes.I know that
export =andimport x = require()are CommonJS-only syntax.