Modules & Resolution
Two module systems, still
Section titled “Two module systems, still”JavaScript has two module formats, and TypeScript has to serve both:
- CommonJS (CJS) — the older Node format:
require()andmodule.exports. Synchronous, loaded by resolving files on disk. - ES Modules (ESM) — the standard:
importandexport. Used natively in browsers and modern Node, statically analyzable.
Most confusion in a TypeScript project traces back to a mismatch between the module format you wrote, the format tsc emits, and the format your runtime expects. The module and moduleResolution options are how you keep those aligned.
How resolution works
Section titled “How resolution works”When you write import { x } from "./util", tsc has to turn "./util" into an actual file. The strategy it uses depends on moduleResolution:
flowchart TB q["How is your code loaded?"] --> node["Runs in Node directly"] q --> bundled["Goes through a bundler"] node --> nodenext["moduleResolution: NodeNext respects package.json type + exports"] bundled --> bundler["moduleResolution: Bundler lets the bundler resolve"]
NodeNext/Node16— models modern Node exactly: it reads"type": "module"inpackage.json, honors the"exports"field, and enforces ESM rules (including that you write file extensions in relative imports:import "./util.js").Bundler— for code that goes through Vite, esbuild, or webpack. It relaxes the extension requirement and lets the bundler handle the actual file lookup, matching how those tools behave.Node10(formerlyNode) — the legacy CommonJS algorithm. Avoid it for new projects; it does not understand the"exports"field.
The key mental model: moduleResolution should describe how your code is actually loaded. Node runs it? Use NodeNext. A bundler processes it? Use Bundler.
The extension surprise
Section titled “The extension surprise”Under NodeNext, native ESM requires the extension in the import, and — confusingly — you write the .js extension even though the file on disk is .ts:
// util.ts exists on disk, but the emitted import must point at the emitted .jsimport { helper } from "./util.js"; // ✅ correct under NodeNext ESMimport { helper } from "./util"; // ❌ ESM needs the extensionThis trips up everyone once. The reason: tsc does not rewrite your import paths, and at runtime the file will be util.js, so the source must already name the runtime file.
esModuleInterop
Section titled “esModuleInterop”CJS and ESM disagree about default exports, which historically made import express from "express" fail against CommonJS modules. esModuleInterop: true inserts the small interop shim that makes default and namespace imports of CommonJS modules behave the way you expect. It is on by default in the recommended configs and you almost always want it — turning it off is a source of avoidable import errors.