Resolving type:module Conflicts in Legacy Projects
Fix ERR_REQUIRE_ESM in legacy Node.js projects. Override tsconfig module resolution, patch package.json conditional exports, and validate dual-format outputs in CI.
Adding "type": "module" to package.json unlocks native ESM in Node.js, but in a codebase that already ships CommonJS the immediate result is often ERR_REQUIRE_ESM: require() of ES Module not supported. This error fires at the resolution boundary where a synchronous require() call meets a file that Node.js now treats as ESM — either because the package root declares "type": "module" or because the file carries a .mjs extension. The problem compounds when TypeScript is involved: compiler settings that predate the node16/nodenext resolver misread exports conditions, producing phantom TS2307: Cannot find module errors even when the runtime would succeed.
Root Cause
The conflict arises from the way Node.js determines module format, a behaviour covered in depth under Browser vs Node.js Module Resolution. Node.js evaluates "type" at the package boundary, not the file level. Once "type": "module" is present, every .js file in that package scope becomes an ES module, making synchronous require() calls into it illegal. Legacy toolchains — older Jest transforms, ts-node without esm: true, scripts that use createRequire without adjusting the file extension — all hit this wall simultaneously.
A secondary root cause is TypeScript’s resolution mode. The moduleResolution: "node" setting predates the exports field and ignores it entirely. It resolves types by scanning main and loose index.d.ts paths, so it cannot find the correct .d.ts entry when a dependency ships separate CJS and ESM type declarations behind conditional exports.
Minimal Reproduction
The smallest trigger is three files:
// package.json — the type field turns every .js into an ESM file
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.js"
}
// dist/index.js — valid ESM
export const greet = (name) => `Hello, ${name}`;
// consumer.js — legacy CJS consumer
const { greet } = require('./dist/index.js'); // ERR_REQUIRE_ESM here
console.log(greet('world'));
Running node consumer.js throws:
Error [ERR_REQUIRE_ESM]: require() of ES Module ./dist/index.js not supported.
For the TypeScript variant, a tsconfig.json with "moduleResolution": "node" paired with a dependency that exposes types only via exports.types produces:
error TS2307: Cannot find module 'my-lib' or its corresponding type declarations.
Resolution Workflow Diagram
Step-by-Step Fix
Step 1 — Rename the legacy entry point to .cjs
The .cjs extension unconditionally marks a file as CommonJS regardless of the package-level "type" field. Renaming avoids touching any consumer that still depends on require().
# Before
mv dist/index.js dist/index.cjs
# Update any internal require() references to use the new name
sed -i 's|require("./index")|require("./index.cjs")|g' src/bootstrap.js
Before:
// consumer.js
const lib = require('./dist/index.js'); // throws ERR_REQUIRE_ESM
After:
// consumer.js
const lib = require('./dist/index.cjs'); // resolves cleanly as CJS
Step 2 — Add a conditional exports map with explicit format paths
Relying on the legacy main field leaves format selection to the resolver’s heuristic. A proper exports field removes the ambiguity by mapping each condition to an exact file. Always place types first and default last, matching the top-to-bottom priority order Node.js uses.
Before (package.json):
{
"type": "module",
"main": "./dist/index.js",
"module": "./dist/esm/index.js"
}
After (package.json):
{
"type": "module",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"node": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
},
"browser": "./dist/browser/index.js",
"default": "./dist/esm/index.mjs"
}
}
}
HAZARD PREVENTION: Retaining top-level
mainalongside a fully-specifiedexportsmap forces Node.js 12 and older bundler versions to use the legacy path as a fallback, bypassing therequire/importconditions you carefully defined. Removemainandmoduleonceexportsis in place, unless you are explicitly targeting Node.js below v12.7.
Step 3 — Switch tsconfig.json to NodeNext resolution
The legacy "moduleResolution": "node" mode predates package.json exports and cannot read them. Upgrading to NodeNext (or node16) forces TypeScript to respect the exports map and correctly resolve .mjs/.cjs extensions. Pair verbatimModuleSyntax with the upgrade to prevent the compiler from silently emitting the wrong module format.
Before (tsconfig.json):
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node"
}
}
After (tsconfig.json):
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true
}
}
HAZARD PREVENTION: Setting
"module": "NodeNext"without also updating"moduleResolution"leaves a mismatched pair that produces inconsistent emit. TypeScript will warn about this, but the build still succeeds, masking the problem until runtime.
Step 4 — Purge stale CI/CD cache artifacts
Mixed CJS/ESM build artifacts accumulate across CI pipeline runs and cause environment-specific failures that do not reproduce locally. Always clean before compiling in CI:
rm -rf dist/ .tscache/ node_modules/.cache/
For legacy CI runners that lack native ESM support, inject an explicit conditions flag before running Node:
NODE_OPTIONS="--conditions=node" node dist/cjs/index.cjs
Verification Command
Run all three checks as a single command to confirm the fix is complete. A clean exit (status 0 from each) means the TypeScript types resolve, the ESM output is valid, and the CJS output is valid:
tsc --noEmit --project tsconfig.json \
&& node --check dist/esm/index.mjs \
&& node --check dist/cjs/index.cjs
Expected output on success:
# No output — all three commands exit 0
Expected output when the ESM file still contains a require() call:
/project/dist/esm/index.mjs:3
const dep = require('legacy-dep');
^
ReferenceError: require is not defined in ES module scope
For package correctness beyond just TypeScript, run publint and attw (are the types wrong?) against the built output:
npx publint && npx attw --pack .
publint catches path mismatches between exports keys and real files; attw verifies that TypeScript consumers will pick up the correct .d.ts for each condition.
Edge Cases and Gotchas
-
pnpm strict hoisting: pnpm’s non-flat
node_modulesmeans a CJS wrapper that resolved a hoisted ESM peer under npm may fail withERR_REQUIRE_ESMin a pnpm workspace. Add affected packages topublic-hoist-patternin.npmrc, or rely on the dependency’srequireconditional export instead. -
TypeScript
strictmode +esModuleInterop: WithverbatimModuleSyntaxenabled, TypeScript rejectsimport Foo = require('...')syntax in.mtsfiles. Replace CJS-style imports withimport typeplus a separate value import. -
Vite vs webpack
conditionNames: Vite resolvesbrowserbeforenodeby default; webpack resolvesnodebeforebrowserunless you setresolve.conditionNamesexplicitly. A package with both conditions in its exports map may load a different format in each bundler, causing subtle divergence between dev and production builds. -
Jest without
extensionsToTreatAsEsm: Jest’s CommonJS transform intercepts.jsfiles before Node.js sees them. After adding"type": "module", add"extensionsToTreatAsEsm": [".ts"]tojest.config.jsand switch to--experimental-vm-modules, or migrate to Vitest which supports ESM natively. -
ts-nodewithoutesm: true:ts-nodedefaults to CJS transpilation. Runningts-node src/index.tsin a"type": "module"project fails immediately. Either add"esm": truetotsconfig.jsonunder"ts-node", or switch tots-node --esm. -
Monorepo dependency hoisting: In Yarn Berry (
nodeLinker: node-modules) or npm workspaces, packages innode_modulesmay be shared across workspace roots. A package that resolves as ESM for one workspace’s node_modules copy but as CJS for another’s can trigger the dual-package hazard where singleton state is duplicated across format boundaries.
FAQ
Why do I still get ERR_REQUIRE_ESM after adding "type": "module" to package.json?
Adding "type": "module" marks your own source files as ESM, but any dependency that ships only a CJS build will throw ERR_REQUIRE_ESM if you call require() on it. Either wait for the dependency to ship an ESM build, wrap the call in a dynamic import() (which is asynchronous), or use createRequire(import.meta.url) to create a scoped require function that can load CJS from an ESM context.
Can I mix .mjs and .cjs files in a project that already has "type": "module"?
Yes. The explicit extensions .mjs and .cjs override the package-level type field. A .cjs file is always treated as CommonJS regardless of "type": "module", which makes it safe to keep legacy bootstrapping scripts, configuration loaders, or CLI shims without removing the top-level flag.
My tsconfig uses moduleResolution: "node" — will that cause type conflicts?
Yes. The node resolution mode predates the exports field and ignores it. TypeScript will fail to locate .d.ts files that are exposed only through conditional exports, producing TS2307. Switch to moduleResolution: "node16" or "bundler" so the compiler reads exports conditions and resolves .mjs/.cjs extensions. See path mapping and module resolution strategies for a broader treatment of TypeScript resolver options.
Does pnpm hoisting affect ERR_REQUIRE_ESM failures?
It can. pnpm’s strict non-flat node_modules means a nested CJS wrapper that works under npm’s flat layout may not find a hoisted ESM peer. Add the affected packages to public-hoist-pattern in .npmrc, or use the dependency’s require conditional export so both formats resolve cleanly.
Will Vite or webpack automatically handle "type": "module" vs require() conflicts?
Bundlers negotiate module format through conditionNames at build time, so many conflicts are invisible during development. The error surfaces at runtime in Node.js when the built output or a test runner (Jest, Vitest, ts-node) invokes require() on an ESM-only entry point. Always run node --check on the built artifacts in CI, not just the bundler output.
Related
- Browser vs Node.js Module Resolution — how Node.js and browsers pick different module entry points from the same package, and the resolution algorithm each one applies.
- Fixing
require()Errors in Pure-ESM Packages — a focused walkthrough for packages that have fully dropped CJS and need to guide legacy consumers toward compatibility. - Mastering the
package.jsonexportsField — full reference for condition ordering, path matching, and subpath patterns.