Skip to content

Modules & Resolution

JavaScript has two module formats, and TypeScript has to serve both:

  • CommonJS (CJS) — the older Node format: require() and module.exports. Synchronous, loaded by resolving files on disk.
  • ES Modules (ESM) — the standard: import and export. 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.

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"]
Choosing a moduleResolution mode
  • nodenext — models the current Node exactly: it reads "type": "module" in package.json, honors the "exports" field, and enforces ESM rules (including that you write file extensions in relative imports: import "./util.js"). Setting module: nodenext implies the matching resolution. node16 is the same idea frozen at Node 16.
  • node18 (5.8) / node20 (5.9) — stable snapshots of nodenext for teams pinned to a Node line. nodenext moves as Node moves; node20 will not change under you. module never sets target on its own — tsc --showConfig --module node20 shows no target key at all, and code using ES2025 APIs (with lib set accordingly) compiles fine under module: node20 at the compiler’s default target. Pick target independently, based on what your runtime supports. The difference that matters most between node18 and node20: node18 still allows the deprecated assert import syntax and forbids require() of ESM; node20 and nodenext are 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 with module: commonjs as well as esnext/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.

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 .js
import { helper } from "./util.js"; // ✅ correct under nodenext ESM
import { helper } from "./util"; // ❌ ESM needs the extension

This 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 disk
import { 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 alone

This is what makes “run the .ts directly in Node during development, emit .js for production” work without two sets of 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-level await). TypeScript 5.8+ understands this under nodenext, 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 lets package.json map "imports": { "#/*": "./dist/*" }, and 6.0 resolves import { x } from "#/util.js" under nodenext and bundler. It is the sanctioned replacement for the baseUrl trick, scoped to your own package and understood by Node itself.

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 module
import config from "./config.json" with { type: "json" }; // ✅
import { port } from "./config.json" with { type: "json" }; // ❌ no named exports from JSON
import config from "./config.json" assert { type: "json" }; // ❌ deprecated syntax

TypeScript 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 module
import 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.

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.

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 output
console.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 prose
const data = require("node:fs");
console.log(data);
Open in TS Playground

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: false
esmInstance instanceof CjsCounter: false
esmInstance instanceof EsmCounter: true

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

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.

ChoiceBenefitCost
nodenext/node18/node20Matches what Node actually does — package.json type, exports, extension rules all enforcedRequires explicit .js extensions in relative imports (or rewriteRelativeImportExtensions)
bundlerNo extension requirement; matches how Vite/webpack/esbuild resolveDoesn’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) buildsServes both kinds of consumers natively, no interop shimReal 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 conditionDefends against less careful consumers/bundlers that iterate conditions by positionVerified 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
  • Putting "types" first in an exports condition is required for TypeScript to find your declaration file. Verified against TypeScript 7.0.2 directly: it is not — tsc looks the "types" key up by name, not by position. The convention protects against other, less careful tools, not against tsc itself.
  • require in an ESM file and import.meta in a CJS file are caught the same way. They aren’t. import.meta in CJS is a real syntax restriction (TS1470) that fires no matter what’s installed. require in ESM is an ordinary undeclared-name lookup — it only errors when nothing in scope declares a global require, and most real projects have @types/node doing 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.for registry, or a module-level cache is a separate instance per build — proven above with a real Node process where the same class fails instanceof against itself across the two entry points.
  • bundler resolution mode is a strictly more permissive version of nodenext. It’s different, not just looser: it never distinguishes require/import conditions, 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.
  • Does a generated exports map put "default" before "types" in a condition object? It likely still works with tsc 7.0.2 (verified above), but flag it for the wider-ecosystem convention anyway — other tools are not as forgiving.
  • Does a package.json split import/require into separately-built files without checking whether the package exports anything with identity (a class meant for instanceof, 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-level await? That specific combination — CJS require of an ESM file with top-level await — still fails even on Node 22+.
  • Does generated code call require() inside a file that nodenext will treat as ESM? tsc --strict will not reliably catch this — with @types/node installed (as it usually is), require type-checks as a valid global with zero errors, and the mistake only surfaces as a runtime ReferenceError the 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.

What should moduleResolution be set to describe?
Under nodenext ESM, what extension do you write when importing ./util.ts?
What is the status of esModuleInterop in TypeScript 7?
Which moduleResolution value is a hard error in TypeScript 7?
How do you import a JSON file under module: nodenext?
In an ESM-detected file, tsc --strict reports TS2591 when no Node types are loaded, but reports **zero errors** on the identical code once "types": ["node"] is set. Why the difference, and what actually happens when this file runs?
const data = require("node:fs");
console.log(data);
A package splits exports into an ESM .mjs build and a CJS .cjs build, each exporting "the same" Counter class. A consumer imports it both ways in one process and gets two different Counter references where instanceof fails across them. Why?