ES Modules in TypeScript
Intermediate · 20 min read · ▶ live playground · ✦ checkpoint
ES modules are how TypeScript turns one growing program into named file boundaries. TypeScript checks those boundaries before JavaScript runs: every import must resolve to a real exported name, and type-only imports can disappear from the emitted JavaScript completely.
One practical note for this lesson: this site's playground is intentionally single-file, so it rejects import and export. The module examples below are static compiler-shaped fences, labeled as files you would put in a real project.
Import, export, and resolution
An export is a public promise from one file. An import is another file asking for that promise by name. Here is a small module with named exports, type exports, and one default export:
A static module file — shown read-only because the playground runs single-file code and does not execute module exports.
export type Currency = "USD" | "EUR";
export type ReceiptLine = {
label: string;
cents: number;
};
export function formatMoney(cents: number, currency: Currency): string {
return `${currency} ${(cents / 100).toFixed(2)}`;
}
export default function formatReceipt(
lines: ReceiptLine[],
currency: Currency,
): string {
return lines
.map((line) => `${line.label}: ${formatMoney(line.cents, currency)}`)
.join("\n");
}Another static module file — read-only because its import depends on the neighboring file above.
import formatReceipt, { formatMoney, type Currency } from "./money";
import type { ReceiptLine } from "./money";
const currency: Currency = "USD";
const lines: ReceiptLine[] = [
{ label: "Course", cents: 5900 },
{ label: "Workshop", cents: 1500 },
];
console.log(formatReceipt(lines, currency));
console.log(formatMoney(500, currency));When TypeScript sees ./money, it resolves that specifier from src/checkout.ts to a neighboring module. Depending on the project settings you will study in 9.2, that can mean looking for files such as money.ts, money.tsx, money.d.ts, or package metadata. For an npm package import such as import { z } from "zod", it walks package metadata and declaration files instead; 9.3 teaches that .d.ts side of the story.
Keep the split clear: TypeScript resolving an import means "the checker found the shape." Your runtime or bundler still has to load the JavaScript module. If the compiler config, emitted JavaScript, and runtime loader disagree, the types can be happy while Node or the browser still cannot start the program.
Type imports are allowed to vanish
Currency and ReceiptLine are type-layer names. They help the checker, but there is no runtime Currency object to import. That is why TypeScript gives you two type-only spellings:
import type { ReceiptLine } from "./money";when the whole import is type-only.import { formatMoney, type Currency } from "./money";when a real value and a type come from the same module.export type Currency = ...orexport type { Currency }when re-exporting type-layer names.
This is the same erasure rule you saw in 1.5, now at a module boundary. A checked single-file version can prove the export type half:
export type PublicReceipt = {
id: string;
totalCents: number;
};
const receipt: PublicReceipt = { id: "r-100", totalCents: 6400 };
console.log(receipt.id + ": " + receipt.totalCents);const receipt = { id: "r-100", totalCents: 6400 };
console.log(receipt.id + ": " + receipt.totalCents);The exported type and the annotation both disappeared. In a real multi-file build, an import type line disappears for the same reason: it was never a runtime dependency.
Here is the diagnostic you get when that modern flag catches an ordinary import used only as a type:
Tool transcript: npx tsc --noEmit with "verbatimModuleSyntax": true
src/main.ts(1,10): error TS1484: 'Profile' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.The fix is not to loosen the type. It is to write import type { Profile } from "./profile"; so the emitted JavaScript does not pretend there is a runtime value called Profile.
Default exports are a style choice
Named exports give the exported value one canonical name:
export function formatMoney(...) pairs with import { formatMoney } from "./money".
A default export gives the module one unnamed main value:
export default function formatReceipt(...) can be imported as formatReceipt, renderReceipt, or anythingTheConsumerChooses.
That flexibility is why many teams ban default exports in application code. Named exports make auto-imports steadier, refactors clearer, and code review easier because the imported name matches the exported API. Default exports still appear in framework conventions and some package APIs, so you need to read them fluently. Just do not assume they are the safer default.
CommonJS interop is a compatibility layer
ES modules use import and export. Older Node packages often use CommonJS: require(...) and module.exports = value. TypeScript can model both, but the boundary has sharp edges because the module systems do not have the same shape.
The most common trap is treating a CommonJS export = package as if it had a real ESM default export. With esModuleInterop off, TypeScript says the quiet part out loud:
Tool transcript: npx tsc --noEmit with "esModuleInterop": false
src/main.ts(1,8): error TS1259: Module '"legacy-logger"' can only be default-imported using the 'esModuleInterop' flagThe practical rule: use the import style the package's docs and your project config expect. For your own modules, prefer named exports unless a framework or public API convention gives a concrete reason to default-export.
You now have the module boundary model: exported names form a checked API, type-only imports erase, and config decides how module syntax is emitted and resolved. Next lesson opens tsconfig.json, because every sharp edge in this lesson becomes clearer once you can read the flags.
Checkpoint
Answer all three to mark this lesson complete