Module Resolution, ESM, and CJS
Why an import that looks correct fails at runtime: the two module systems, resolution modes, file extensions, and reading the error instead of guessing at config.
Why an import that looks correct fails at runtime: the two module systems, resolution modes, file extensions, and reading the error instead of guessing at config.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/dist/captions' imported from /app/dist/main.jsThe file exists. The editor resolves the import. tsc --noEmit
passes. It fails only when you run it, which is the worst error
class in this language — the checker satisfied and the program
broken.
There are two module systems here, several resolution strategies, and a compiler that emits for one while your runtime expects another. By the end of this lesson you will be able to read any of these errors and know which of four settings caused it.
CommonJS came first, in Node:
const { makeCaption } = require("./captions");
module.exports = { makeCaption };ES Modules are the standard, used by browsers and now by Node:
import { makeCaption } from "./captions.js";
export { makeCaption };Three differences produce every problem in this lesson.
Resolved while the program runs.
require runs at the moment it is reached, so the path can
be computed.
It guesses extensions — ./captions finds captions.js,
then captions/index.js. And there is no top-level await.
Resolved before any code runs.
Which is what lets a bundler see the whole graph and remove the exports nobody imports.
Nothing is guessed: the path must be exact, extension
included. Top-level await works.
Node decides per file, from two things:
.mjs — always ESM
.cjs — always CommonJS
.js with "type": "module" nearby — ESM
"Nearby" means the closest package.json walking up from the
file.
.js with anything else — CommonJS
Including no package.json at all, and including the case
where you meant to add the field and did not.
And the TypeScript equivalents: .mts is ESM, .cts is
CommonJS, .ts follows the same package.json rule.
So the first question for any resolution error is: what does
the nearest package.json say? A missing "type": "module"
means every .js file in that tree is CommonJS, whatever the
import syntax you wrote in TypeScript.
Bad — an extensionless import in an ESM project.
// src/main.ts
import { makeCaption } from "./captions";tsc --noEmit passes
node dist/main.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/captions'Good — the output extension.
import { makeCaption } from "./captions.js";You wrote captions.ts and import captions.js, which reads
like a mistake and is correct. Import paths refer to what exists
after compilation, and the compiler emits .js. Your editor
resolves ./captions.js back to captions.ts.
The first version compiles to require("./captions") under
CommonJS and works, or to import ... from "./captions" under
ESM and fails at runtime — because Node's ESM resolver does not
guess extensions. The checker never objects, because it resolves
paths using its own strategy rather than the runtime's.
Whether you need the extension depends on one setting:
"moduleResolution": "NodeNext" -> extension REQUIRED
"moduleResolution": "Bundler" -> optional{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"verbatimModuleSyntax": true
}
}module is what to emit — require calls or import
statements.
moduleResolution is how to find files, and it must match
what will actually run.
target is the language level of the output, unrelated to
modules despite being adjacent in every config.
verbatimModuleSyntax stops the compiler guessing whether
an import can be elided, and requires import type where it
applies.
Two combinations cover nearly everything:
// Node, running the compiled output directly
"module": "NodeNext", "moduleResolution": "NodeNext"
// + "type": "module" in package.json, + .js extensions
// anything with a bundler: Vite, webpack, esbuild, Next
"module": "ESNext", "moduleResolution": "Bundler"
// extensions optionalNodeNext reads package.json and follows Node's real rules —
which is why it insists on extensions, and why it is the honest
choice when Node runs your output. Bundler matches what
bundlers do, which is more forgiving.
The obsolete value is "moduleResolution": "Node", which
implements the CommonJS algorithm and does not understand the
exports field. If you inherit a project with it and imports
behave strangely, that is the first thing to change.
Most of the ecosystem is still CommonJS, and mixing directions is where the remaining confusion lives.
ESM importing CommonJS works, with a caveat:
import express from "express"; // the module.exports object
import { Router } from "express"; // may fail - named exports
// are detected, not guaranteedNode analyses the CommonJS file to find named exports, and the analysis is static — a module that assigns exports conditionally defeats it. When a named import fails from a CommonJS package, import the default and destructure:
import pkg from "some-cjs-package";
const { thing } = pkg;esModuleInterop: true makes the TypeScript side of this work
as expected, and it is on by default in modern configs.
CommonJS importing ESM does not work with require — ESM
is asynchronous and require is not. Use a dynamic import:
const { makeCaption } = await import("./captions.js");Dynamic import() returns a promise and works from both
systems, which makes it the escape hatch for this and for
loading something conditionally.
A modern package declares its entry points explicitly:
{
"name": "photo-tools",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./captions": {
"types": "./dist/captions.d.ts",
"import": "./dist/captions.js"
}
}
}Two things follow that surprise people.
exports is a boundary. Anything not listed cannot be
imported, even if the file is in the published package.
import "photo-tools/dist/internal.js" fails — which is a
feature, since it lets you reorganise internals without breaking
anyone.
types must come first in each condition block. Resolution
takes the first match, so a types entry after import is
never reached, and consumers get no types with no explanation.
If a package you depend on has no exports and resolution is
strange, that is the older main/module world, and
moduleResolution: "Node" is what reads it.
Four messages and what each means:
ERR_MODULE_NOT_FOUND
-> a missing .js extension, or the file is not where the
compiled output puts it
ERR_REQUIRE_ESM
-> CommonJS code is requiring an ESM package
use dynamic import(), or make the caller ESM
"does not provide an export named 'X'"
-> a named import from CommonJS that Node could not detect
import the default and destructure
"Cannot find module 'x' or its corresponding type declarations"
-> a TYPE-time error, not runtime: the package has no types,
or the exports map has types in the wrong positionThat last distinction is worth internalising. The first three are runtime failures where the checker was happy; the fourth is the checker, and the program may run fine.
TWO SYSTEMS
CommonJS require / module.exports resolved at runtime,
guesses extensions, no top-level await
ESM import / export resolved before running,
exact paths, top-level await
WHICH ONE A FILE IS
.mjs/.mts ESM .cjs/.cts CommonJS
.js/.ts ESM if the nearest package.json has "type": "module"
first question for any resolution error: what does it say?
THE EXTENSION
import { x } from "./captions.js"; the OUTPUT path
NodeNext -> required Bundler -> optional
tsc resolves with ITS strategy; Node uses its own
-> checker passes, runtime fails
THE FOUR SETTINGS
module what to emit
moduleResolution how to FIND files - must match the runtime
target language level, unrelated to modules
verbatimModuleSyntax requires import type; no guessing
Node: "NodeNext" + "NodeNext" + "type": "module" + .js
bundler: "ESNext" + "Bundler"
"Node" is obsolete - it ignores the exports field
MIXING
ESM importing CJS default import always; named imports are
DETECTED, not guaranteed -> import default,
then destructure
CJS importing ESM require fails; use await import()
esModuleInterop on by default in modern configs
a dual package can load TWICE - instanceof then fails
exports
a boundary: anything unlisted cannot be imported
"types" must be the FIRST key in each condition block
READING ERRORS
ERR_MODULE_NOT_FOUND missing extension / wrong output path
ERR_REQUIRE_ESM CJS requiring ESM
"no export named X" CJS named-export detection failed
"or its type declarations" a TYPE error, not a runtime oneYou can now diagnose an import that passes the checker and fails
at runtime, which is otherwise an hour of guessing. The single
most useful fact is that tsc resolves paths with its own
strategy — so matching moduleResolution to what actually runs
your code is what makes the two agree.
Next is Project Structure and References, which scales this up. Once a codebase has several packages, the questions become how to check them without rechecking everything, and how to stop one part importing another it should not.
Before you move on, look at your tsconfig.json and your
package.json together. Check whether moduleResolution
matches how the code actually runs, and whether "type" is set.
Those two lines explain most import errors people spend an
afternoon on.