⚠️ この記事はアフィリエイト広告(プロモーション)を含みます。リンク先で発生した収益の一部が運営者に支払われますが、読者の購入価格には一切影響ありません。
If you just added "type": "module" to package.json and your test runner died with ERR_REQUIRE_ESM, this article gets you back to green in about 15 minutes. By the end you'll have a copy-pasteable decision map for .js / .cjs / .mjs, a working dual-format build, and the three config files (Jest, ESLint, a small CLI) that break most often — with the exact error strings so you can Ctrl+F your stack trace.
I migrated a 42-file internal tooling repo last month. The flag flip took 4 seconds; the fallout took 3 hours. Here's the part nobody writes down.
The one rule that explains every ERR_REQUIRE_ESM and ERR_MODULE_NOT_FOUND
Result first: in Node 20–24, the "type" field in the nearest package.json decides how .js files are parsed. "type": "module" → every .js is ESM. No field, or "type": "commonjs" → every .js is CJS. The extensions .cjs and .mjs ignore the field entirely and are always CJS and ESM respectively.
So 90% of migration pain is one of two mismatches:
- A
.jsfile still usesrequire()/module.exports, but"type": "module"now parses it as ESM →ReferenceError: require is not defined in ES module scope. - Some dependency or your own code does
require('./thing')where./thingis now ESM →Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.
Keep this table next to your editor. It resolves the ambiguity faster than reading the Node docs again:
| File | Under "type": "module"
|
Under "type": "commonjs" (or absent) |
|---|---|---|
foo.js |
ESM (import/export) |
CJS (require/module.exports) |
foo.mjs |
ESM | ESM |
foo.cjs |
CJS | CJS |
The practical takeaway: when you're not ready to convert a file, rename it .cjs instead of fighting the field. That single move fixed 11 of my 42 files in seconds.
Reproduce the three errors in 60 seconds with one shell script
Don't trust prose — make the errors happen so you recognize them later. Save this as repro.sh (works on macOS/Linux; on Windows run it in Git Bash or WSL) and run it. It builds a tiny project, flips the flag, and prints each failure:
#!/usr/bin/env bash
set -u
rm -rf esm-repro && mkdir esm-repro && cd esm-repro
npm init -y >/dev/null
# A CJS-style file that will be misparsed once type:module is on
cat > legacy.js <<'EOF'
const os = require('os');
module.exports = () => os.platform();
EOF
cat > main.js <<'EOF'
import getPlatform from './legacy.js';
console.log('platform:', getPlatform());
EOF
echo '--- 1) Before the flag (CJS world, import in .js fails) ---'
node main.js 2>&1 | head -3
echo '--- 2) After the flag (ESM world, require in .js fails) ---'
node -e "const p=require('./package.json'); p.type='module'; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2));"
node main.js 2>&1 | head -3
echo '--- 3) The fix: rename the CJS file to .cjs and import the named build ---'
mv legacy.js legacy.cjs
cat > main.js <<'EOF'
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const getPlatform = require('./legacy.cjs');
console.log('platform:', getPlatform());
EOF
node main.js 2>&1 | head -3
On my machine (Node 22.11, macOS) step 1 prints SyntaxError: Cannot use import statement outside a module, step 2 prints ReferenceError: require is not defined in ES module scope, and step 3 prints platform: darwin. That createRequire(import.meta.url) line is the single most useful escape hatch in the whole ESM world — it gives an ESM file a real require() so you can pull in a stubborn CJS-only dependency (looking at you, older versions of some config loaders) without converting it.
Shipping both formats: a package.json exports map that survived npm publish
If you publish a library, half your users are on CJS and half on ESM, and you cannot please both with a single entry point. The exports field with conditional keys is the answer. This is the exact shape I shipped — it works with import, require, and TypeScript's node16/nodenext resolution simultaneously:
{
"name": "@acme/widget",
"version": "2.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean"
}
}
Three things that bit me and aren't obvious:
-
Order matters inside the condition object.
typesmust come first, thenimport, thenrequire. Node and TypeScript read top-to-bottom and stop at the first match; putrequirefirst and ESM consumers silently get the CJS build. -
Always export
./package.json. Tools like Vite, Jest, and some bundlers read it at runtime; omit it and you getERR_PACKAGE_PATH_NOT_EXPORTEDfrom deep inside a dependency you don't control. I lost ~40 minutes to that one because the error names your package, not the tool. -
The CJS build needs the
.cjsextension, not.js. Because the root"type": "module"would otherwise parsedist/index.js's CJS twin as ESM.tsup --format cjshandles this for you and writes.cjsautomatically; hand-rolledtscsetups do not.
I verified the published artifact with npm pack && tar -tf acme-widget-2.0.0.tgz before pushing — npm publish does not warn you if your exports paths point at files that don't exist, it just ships a broken package. Twelve downloads happened before I'd have noticed otherwise.
The Jest + ESLint flat-config trap that ate my afternoon
The two config files most likely to break are Jest's and ESLint's, for opposite reasons.
Jest still runs its config and many transforms through CJS. After flipping "type": "module", a jest.config.js with module.exports = {...} throws ReferenceError: module is not defined in ES module scope. The fix is a one-character rename, not a rewrite: jest.config.js → jest.config.cjs. Jest finds it automatically. If you also want ESM test files to actually run as ESM, you need NODE_OPTIONS=--experimental-vm-modules in the test script — without it, import inside tests gets transpiled to require and you're not really testing your ESM build.
ESLint 9 flat config is the inverse trap. eslint.config.js is loaded as ESM under "type": "module", so it must use export default, not module.exports. If you copied an old .eslintrc.js snippet into it, you'll see SyntaxError: Unexpected token 'export' or the dreaded silent "0 problems" because ESLint failed to load the config and fell back to defaults. The giveaway: run npx eslint --print-config somefile.js and check whether your rules actually appear.
Here's the combination that finally went green for me, in package.json:
{
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
"lint": "eslint ."
}
}
with jest.config.cjs (CJS) and eslint.config.js (ESM export default) coexisting in the same repo. Yes, two config files in two different module systems, side by side — and that's correct, not a smell.
__dirname is gone — the 2-line shim I paste everywhere
ESM has no __dirname or __filename. Every file that built a path relative to itself breaks with ReferenceError: __dirname is not defined. In my repo this was 9 files, mostly things reading a template or a fixture. The replacement is short enough to memorize:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// now this works exactly like before
const template = join(__dirname, 'templates', 'email.html');
On Node 21.2+ there's a shorter form — import.meta.dirname and import.meta.filename are built in, so you can drop the shim entirely if your minimum runtime is recent enough. I checked our engines field said >=20, so I kept the shim for one more release rather than break the two coworkers still on Node 20.
What I'd actually recommend: don't flip the flag on day one
After doing this migration twice, my real advice contradicts most tutorials: for an existing app (not a library), migrating file-by-file with .mjs is calmer than flipping "type": "module" repo-wide. Rename one file to .mjs, convert it to import/export, run your tests, repeat. Nothing else in the repo changes, there's no big-bang failure, and you can stop halfway and ship. I flipped the global flag only at the very end, once every remaining .js was already ESM-clean — at which point the flag was a no-op confirmation rather than a 3-hour cliff.
If you're starting a brand-new project, do the opposite: set "type": "module" in the first commit, write everything as ESM, and reserve .cjs purely for the handful of config files that demand it. The mixed-mode mess only exists in the migration, not in greenfield.
The whole model collapses to one sentence worth remembering: the "type" field controls .js, the extensions .cjs/.mjs override the field, and createRequire is your bridge when you can't convert a file yet. Keep that and the extension table above, and ERR_REQUIRE_ESM stops being a mystery.
Top comments (0)