Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~22 min· topic 1 of 6

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 import or export. Its names are private unless exported.
Script
A file with no import or export. 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/export system, used by browsers and modern Node.js.
CommonJS
Node's older system: require() and module.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.

src/shapes.tswhole filets
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).

src/main.tswhole filets
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.

dist/main.jswhole filets
// 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.57

04Re-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).

src/reexp.tswhole filets
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.

Which files are modules?diagram
Rendering diagram…

Try it yourself

  1. 1

    Predict, then check

    In the first example, change export type Id = string | number; to type Id = string | number; (no export). Nothing in this file uses Id, so predict: does anything break? (No. A file only exports what it says, and the rest is private.)

  2. 2

    Make the script a module

    In the second example, add a line export {}; at the end. Predict the Types panel first. The Cannot redeclare error disappears, because the file is now a module and name is no longer global.

  3. 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

Every way to export, and what imports will see

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)
string
A script's names are global: the clash with window.name

This 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

Asha
CommonJS-only syntax is an error in an ES module

export = 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 project: tsconfig.json with verbatimModuleSyntax TypeScript 5.0+json

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.

terminal
$ tsc -p .
── what you'll see ──
src/main.ts(1,16): error TS1484: 'Circle' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

Break #2

Re-export an interface

Add src/reexp.ts containing export { Circle } from "./shapes.js"; with isolatedModules on.

terminal
$ tsc -p . --noEmit
── what you'll see ──
src/reexp.ts(1,10): error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.

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.

  • ▸

    verbatimModuleSyntax replaces two older flags, importsNotUsedAsValues and preserveValueImports, and (mostly) removes the need for isolatedModules. The two old flags are removed: TypeScript 6.0 reports error 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 with verbatimModuleSyntax.

  • ▸

    With verbatimModuleSyntax on and a CommonJS output, plain import/export syntax 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 write import 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. 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 import or export. You write export function area() {} (or export const, export class, export interface, export type) to share something, and import { 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 that interface and type can be exported and imported too.

  2. 2

    Types are erased before the code runs (Topic 0.2). So an import of 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 of area and drops Circle. The matching form for sharing is export type { Circle } (and export type { Circle } from "./shapes.js" to re-export).

  3. 3

    **verbatimModuleSyntax** (since 5.0) makes the rule simple and visible: *what you write is what you get.* An import or export without the word type is kept in the JavaScript exactly as written; one with type is deleted. If you import a name that is only a type without writing type, you get TS1484, because TypeScript would otherwise have to guess whether to keep the import. It's the setting that tsc --init writes, and the one this site's live editor uses.

  4. 4

    **isolatedModules is 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 whether Circle is a type or a value. isolatedModules makes TypeScript refuse code that only works if it can look at other files. The classic case is export { Circle } from "./shapes.js": if Circle is an interface, the one-file tool can't know to erase it. TypeScript reports TS1205 and asks for export type. verbatimModuleSyntax already implies the same safety, so with it on you rarely need to think about isolatedModules.

  5. 5

    **moduleDetection** decides which files are modules. By default ("auto") a file is a module only if it has an import/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 say const name = "x" clash, and why name clashes with the browser's own window.name. Setting "moduleDetection": "force" treats every non-declaration file as a module, which is the sane choice and what tsc --init writes. A lone export {} also turns a file into a module.

  6. 6

    Two older forms still exist and are errors in an ES module project: export = value and import x = require("x"). They are CommonJS-era syntax. In a file that builds to ESM, TypeScript reports TS1203 and TS1202 and tells you to use export 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 by module, and by the type field in package.json: Topic 11.2.

Explain it without notes

01

What is the difference between import type { A } and import { type A }?

02

What does verbatimModuleSyntax do and why was it added?

03

Why does isolatedModules exist?

04

How does TypeScript decide whether a file is a module or a script?

Practice

01

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.

02

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

  • ↔

    verbatimModuleSyntax makes you write type in 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.ts that 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 under verbatimModuleSyntax and 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 moduleDetection does.

  • I know that export = and import x = require() are CommonJS-only syntax.