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. Since 6.0 the default module is esnext — ESM is the assumption, CJS is the thing you opt into.
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, and in TypeScript 7 there are exactly two families left:
flowchart TB q["How is your code loaded?"] --> node["Runs in Node directly"] q --> bundled["Goes through a bundler"] node --> nodenext["module: nodenext (or node18 / node20) respects package.json type + exports"] bundled --> bundler["moduleResolution: bundler lets the bundler resolve"]
nodenext— models the current 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"). Settingmodule: nodenextimplies the matching resolution.node16is the same idea frozen at Node 16.node18(5.8) /node20(5.9) — stable snapshots ofnodenextfor teams pinned to a Node line.nodenextmoves as Node moves;node20will not change under you.modulenever setstargeton its own —tsc --showConfig --module node20shows notargetkey at all, and code using ES2025 APIs (withlibset accordingly) compiles fine undermodule: node20at the compiler’s default target. Picktargetindependently, based on what your runtime supports. The difference that matters most betweennode18andnode20:node18still allows the deprecatedassertimport syntax and forbidsrequire()of ESM;node20andnodenextare the other way round.bundler— for code that goes through Vite, esbuild, Rolldown, or webpack. It relaxes the extension requirement and lets the bundler handle the actual file lookup, matching how those tools behave. Since 6.0 you may pair it withmodule: commonjsas well asesnext/preserve, which gives CJS projects a migration path.
The legacy node / node10 algorithm and classic are gone in TypeScript 7 (deprecated in 6.0). They predate "exports" and package "type", so they described a Node that no longer exists.
The key mental model: moduleResolution should describe how your code is actually loaded. Node runs it? nodenext. A bundler processes it? 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:
// @skip-verify illustrative — "./util(.js)" is not a real module// 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.
Since 5.7 there is an escape hatch. With rewriteRelativeImportExtensions: true you write the real extension and tsc rewrites it on emit — only for relative paths that end in a TypeScript extension:
// @skip-verify illustrative — none of these modules exist on diskimport { helper } from "./util.ts"; // emitted as "./util.js"import { x } from "../lib/thing.mts"; // emitted as "../lib/thing.mjs"import pkg from "some-package/file.ts"; // not relative — left aloneThis is what makes “run the .ts directly in Node during development, emit .js for production” work without two sets of imports.
require() of ESM, and #/ imports
Section titled “require() of ESM, and #/ imports”Two Node features that nodenext models:
require("./esm-module.js")from CommonJS works in Node 22+ (as long as the ESM file has no top-levelawait). TypeScript 5.8+ understands this undernodenext, so a library can ship ESM only and still be consumed by CJS callers — the dual-package problem is much smaller than it used to be.- Subpath imports starting with
#/— Node letspackage.jsonmap"imports": { "#/*": "./dist/*" }, and 6.0 resolvesimport { x } from "#/util.js"undernodenextandbundler. It is the sanctioned replacement for thebaseUrltrick, scoped to your own package and understood by Node itself.
Import attributes, JSON, and import defer
Section titled “Import attributes, JSON, and import defer”The asserts keyword for import assertions is deprecated; the standard is with, and under nodenext a JSON import must carry it and must be a default import:
// @skip-verify illustrative — "./config.json" is not a real moduleimport config from "./config.json" with { type: "json" }; // ✅import { port } from "./config.json" with { type: "json" }; // ❌ no named exports from JSONimport config from "./config.json" assert { type: "json" }; // ❌ deprecated syntaxTypeScript 5.9 also types import defer, the ECMAScript proposal for lazy module evaluation. The module is linked but not run until you first touch an export — useful for expensive initialisation you only sometimes need:
// @skip-verify illustrative — "./heavy-feature.js" is not a real moduleimport defer * as heavy from "./heavy-feature.js";
declare const flag: boolean;if (flag) { heavy.run(); // the module body executes here, not at startup}Only namespace imports are allowed, and tsc does not transform it, so it needs module: esnext or preserve and a runtime that supports it.
esModuleInterop is history
Section titled “esModuleInterop is history”CJS and ESM disagree about default exports, which historically made import express from "express" fail against CommonJS modules unless esModuleInterop inserted a small shim. For years every tutorial began with “turn on esModuleInterop”.
You will still see that line in old configs, but it no longer does anything: esModuleInterop and allowSyntheticDefaultImports are always on in TypeScript 6/7 and cannot be disabled. import express from "express" just works. The only migration step is the reverse one — if a codebase has import * as express from "express" from the pre-interop era, switch it to a default import.
How a file’s module format gets decided
Section titled “How a file’s module format gets decided”nodenext/node18/node20 don’t check a flag on the compiler — they ask three things, in order: does the file end in .mts/.cts (always ESM/CJS respectively, no matter what)? Otherwise, does the nearest package.json say "type": "module"? Otherwise, plain .ts defaults to CommonJS. Two similar-looking cross-format mistakes then get caught in genuinely different ways, worth telling apart:
import.meta used inside a file nodenext has decided is CommonJS is a real syntax error, independent of anything installed:
// @skip-verify only reproduces on a real .cts file under --module nodenext -- see prose for the exact command and outputconsole.log(import.meta.url);error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output (verified in scratch on a real .cts file, under --module nodenext, with @types/node installed and without — same result either way). This is the checker enforcing that a file committed to CommonJS can’t use ESM-only syntax, full stop.
require() used inside an ESM file looks like the mirror image, but it isn’t: it’s an ordinary undeclared-name lookup, not a syntax restriction, and whether it errors depends entirely on whether anything in scope declares a global require — which most real projects do, via @types/node.
No @types/node is loaded in this checker — click Check types
// this checker loads no @types/node -- that absence is exactly the point, see proseconst data = require("node:fs");console.log(data); Click Check types: TS2591: Cannot find name 'require'. Do you need to install type definitions for node? — in this checker (no Node types loaded at all, on purpose — that’s why this snippet is skipped by this course’s own tsc-based CLI check, which always loads --types node) that’s genuinely the fix. But run the identical code through tsc --strict in a real project with "types": ["node"] set, and it compiles with zero errors (verified in scratch): @types/node’s global declarations add require as an ambient global unconditionally, with no awareness of which module format the current file is in. The type checker is satisfied; Node is not — running that file directly throws ReferenceError: require is not defined in ES module scope, you can use import instead (verified in scratch). This is a real, if narrow, gap in what strict can catch: a project with @types/node installed gets zero warning for a mistake that fails every time it runs, and only a typed lint rule looking for “the wrong global in the wrong file” — or a runtime test — closes it.
The exports field: condition order, .d.mts/.d.cts, and the dual-package hazard
Section titled “The exports field: condition order, .d.mts/.d.cts, and the dual-package hazard”A package’s package.json "exports" field can point ESM and CJS consumers at different files, each with its own type declaration, using conditions:
{ "name": "mylib", "type": "module", "exports": { ".": { "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } }}.d.mts and .d.cts are declaration files for .mjs/.cjs outputs specifically — they let a package’s ESM entry and CJS entry carry genuinely different types (rare, but possible when the two builds’ shapes actually differ), matching a nodenext/node16 resolver that also chooses between .mjs/.cjs based on the same conditions.
The convention every guide repeats is “put "types" first in each condition object.” Tested against TypeScript 7.0.2 directly, that convention turned out to be more defensive than load-bearing for tsc itself: moving "types" to the end of the import condition and pointing it at a file with a deliberately wrong export name still resolved correctly — tsc looks up the "types" key by name, not by position, and only fails when there’s no matching declaration file to fall back on at all (verified in scratch: with the sibling .d.mts still present next to the .mjs, tsc found it either way). The advice to lead with "types" earns its keep for less careful consumers of the same exports field — a bundler or older tool that iterates conditions by position rather than looking "types" up by name — so it’s still the right default to write, just not because tsc 7.0.2 requires it.
The dual-package hazard is a real, runtime consequence of splitting import/require into separate files: the two entries are two independent modules to the JavaScript engine, so anything with identity — a class, a Symbol, a Map used as a registry — is not the same thing when reached through the two paths. A real Node process proves it (verified in scratch — an ESM entry point importing a package both ways):
EsmCounter === CjsCounter: falseesmInstance instanceof CjsCounter: falseesmInstance instanceof EsmCounter: trueEsmCounter and CjsCounter come from what looks like “one package” but are two separately-evaluated files, each with its own class identity — an instance created through one entry fails instanceof against the other. This is exactly why tsc’s own resolution, and tools like @arethetypeswrong, treat a package’s dual CJS/ESM split as something to verify, not assume: node16/nodenext resolve the same specifier to the .d.mts or .d.cts declaration depending on which condition the importing file’s own module format selects, while bundler mode (used by Vite/webpack/esbuild) ignores require/import conditions entirely and resolves through default/types alone — a package relying on the CJS/ESM split behaving differently is invisible to a bundler-mode project, since it never evaluates both files at once the way a real dual-consumption Node process can.
Mental Model
Section titled “Mental Model”Every resolution rule in this lesson answers one question that tsc has to settle before it can check a single import: is this file ESM or CJS, and does the target it’s importing agree? .mts/.cts settle it by filename regardless of anything else; plain .ts/.js fall back to the nearest package.json’s "type". Once that’s settled, exports conditions let a package answer the same question differently per consumer — which is exactly the hazard: two consumers asking “is this ESM or CJS” can get two different files back, and if that package’s identity (a class, a shared registry) matters, those two files are not interchangeable, no matter how identical their public API looks on paper.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
nodenext/node18/node20 | Matches what Node actually does — package.json type, exports, extension rules all enforced | Requires explicit .js extensions in relative imports (or rewriteRelativeImportExtensions) |
bundler | No extension requirement; matches how Vite/webpack/esbuild resolve | Doesn’t enforce require/import conditions the way a real dual-consumption Node process would — a dual-package identity bug can be invisible in dev and only appear for a Node-direct consumer |
Splitting a package into separate ESM (.mjs) and CJS (.cjs) builds | Serves both kinds of consumers natively, no interop shim | Real dual-package hazard: each build is a separately-evaluated module, so identity (instanceof, a shared Map/Set registry) breaks across the two |
"types" listed first in an exports condition | Defends against less careful consumers/bundlers that iterate conditions by position | Verified unnecessary for tsc 7.0.2’s own resolution, which finds "types" by name regardless of position — still worth writing as the safer default for the wider ecosystem |
Common Misconceptions
Section titled “Common Misconceptions”- Putting
"types"first in anexportscondition is required for TypeScript to find your declaration file. Verified against TypeScript 7.0.2 directly: it is not —tsclooks the"types"key up by name, not by position. The convention protects against other, less careful tools, not againsttscitself. requirein an ESM file andimport.metain a CJS file are caught the same way. They aren’t.import.metain CJS is a real syntax restriction (TS1470) that fires no matter what’s installed.requirein ESM is an ordinary undeclared-name lookup — it only errors when nothing in scope declares a globalrequire, and most real projects have@types/nodedoing exactly that, so the same mistake silently compiles clean right up until Node refuses to run it.- If a package ships both an ESM and a CJS build, they’re just “the same code in two formats.” Only if nothing in the package has identity. A class, a
Symbol.forregistry, or a module-level cache is a separate instance per build — proven above with a real Node process where the same class failsinstanceofagainst itself across the two entry points. bundlerresolution mode is a strictly more permissive version ofnodenext. It’s different, not just looser: it never distinguishesrequire/importconditions, so it can’t reveal a dual-package hazard that only shows up when a Node process actually loads both entries — a project developed and tested only under a bundler can ship that bug straight to a Node-direct consumer.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a generated
exportsmap put"default"before"types"in a condition object? It likely still works withtsc7.0.2 (verified above), but flag it for the wider-ecosystem convention anyway — other tools are not as forgiving. - Does a
package.jsonsplitimport/requireinto separately-built files without checking whether the package exports anything with identity (a class meant forinstanceof, a shared cache)? That’s exactly the dual-package hazard shape — worth a real two-entry-point test, not just “both builds pass their own unit tests.” - Is a
require("./esm-module.js")call added to “fix” a CJS/ESM interop error, without checking that the target has no top-levelawait? That specific combination — CJSrequireof an ESM file with top-levelawait— still fails even on Node 22+. - Does generated code call
require()inside a file thatnodenextwill treat as ESM?tsc --strictwill not reliably catch this — with@types/nodeinstalled (as it usually is),requiretype-checks as a valid global with zero errors, and the mistake only surfaces as a runtimeReferenceErrorthe first time that exact code path runs. A typed lint rule or a runtime test is what actually catches it here, not the type checker.
🐛 Spot the bug
{"name": "@acme/widgets","type": "module","exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }}}Show the answer
This looks like a clean, modern dual-package setup, and the resolution itself is fine — the bug is what it doesn’t say. Neither condition has a "types" key at all, so both consumers fall back to TypeScript’s implicit lookup: a .d.mts/.d.cts file sitting next to each JS file with a matching name. If a build step ever emits only index.d.ts (a single shared declaration, the more common single-build habit) instead of both index.d.mts and index.d.cts, ESM consumers silently get no types at all for this package (TS7016, “implicitly has an ‘any’ type”) while CJS consumers might still find the shared .d.ts by an older, more forgiving resolution path — two different type-safety outcomes from the same install, with nothing in this file to explain why. Reviewing a dual-package exports map means checking that a matching declaration file actually exists for each condition, not just that the JS resolves.