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

type:module conflict resolution workflow A flowchart showing four sequential steps: rename legacy entry, add conditional exports, update tsconfig, then validate with tsc and node --check. Step 1 Rename legacy entry to .cjs Step 2 Add conditional exports map Step 3 Switch tsconfig to NodeNext Step 4 Verify with tsc + node Common errors & fixes ERR_REQUIRE_ESM Use .cjs extension or dynamic import() TS2307 Cannot find module Set moduleResolution: node16 or nodenext ERR_UNSUPPORTED_DIR_IMPORT Add explicit exports map, remove bare directory paths CI Gate tsc --noEmit && node --check dist/esm/index.mjs && node --check dist/cjs/index.cjs

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 main alongside a fully-specified exports map forces Node.js 12 and older bundler versions to use the legacy path as a fallback, bypassing the require/import conditions you carefully defined. Remove main and module once exports is 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_modules means a CJS wrapper that resolved a hoisted ESM peer under npm may fail with ERR_REQUIRE_ESM in a pnpm workspace. Add affected packages to public-hoist-pattern in .npmrc, or rely on the dependency’s require conditional export instead.

  • TypeScript strict mode + esModuleInterop: With verbatimModuleSyntax enabled, TypeScript rejects import Foo = require('...') syntax in .mts files. Replace CJS-style imports with import type plus a separate value import.

  • Vite vs webpack conditionNames: Vite resolves browser before node by default; webpack resolves node before browser unless you set resolve.conditionNames explicitly. 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 .js files before Node.js sees them. After adding "type": "module", add "extensionsToTreatAsEsm": [".ts"] to jest.config.js and switch to --experimental-vm-modules, or migrate to Vitest which supports ESM natively.

  • ts-node without esm: true: ts-node defaults to CJS transpilation. Running ts-node src/index.ts in a "type": "module" project fails immediately. Either add "esm": true to tsconfig.json under "ts-node", or switch to ts-node --esm.

  • Monorepo dependency hoisting: In Yarn Berry (nodeLinker: node-modules) or npm workspaces, packages in node_modules may 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.


Up: Browser vs Node.js Module Resolution