Module interoperability

1. Runtime-level (Node.js)

Importer \ ImportedCommonJS (CJS)ES Module (ESM)
CommonJS (require)✅ Works (require("./cjs.js"))⚠️ Works but returns wrapper object; named exports not proper
ESM (import)⚠️ Imports module.exports as default only✅ Works natively with live bindings

2. TypeScript module compiler option

TS module settingEmitted outputInterop styleWhen to use
commonjsUses require() + module.exportsCJS default, no static import at runtimeLegacy Node projects, Jest, older tooling
esnext (or es2022)Keeps import/export as-isPure ESM outputBrowser builds, bundlers (webpack, Vite), modern Node ≥ 20
nodenextHybrid: decides per file (.cjs → CJS, .mjs → ESM, .ts depends on package.json "type")Matches Node.js resolution rules exactlyBest choice for modern Node.js projects
node16Like nodenext, but frozen to Node 16 semanticsLegacy compat for Node 16 onlyUse only if targeting Node 16

3. TypeScript + Runtime Interop

TS Import SyntaxCJS Target (module: commonjs)ESM Target (module: esnext)NodeNext (module: nodenext)
import foo from "cjs-lib"✅ Works (compiled to require())❌ Fails unless esModuleInterop is enabled✅ Works if lib is .cjs and Node rules apply
import { bar } from "cjs-lib"⚠️ Only works with esModuleInterop❌ Fails (no named exports in CJS)⚠️ Works only if lib defines __esModule
const foo = require("esm-lib")⚠️ Returns wrapper object❌ Not allowed (require not available in ESM)⚠️ Works only in .cjs files
import { baz } from "esm-lib"❌ Not allowed (CJS doesn’t support import)✅ Works✅ Works

4. Practical Guidance

  • For new Node projects:
    Use "module": "nodenext" in tsconfig.json.
    • This makes TypeScript align with Node’s dual .cjs / .mjs resolution.
    • Works best when package.json has "type": "module".
  • Mixing CJS and ESM:
    • If you import CJS from ESM: use default imports. import pkg from "cjs-lib";
    • If you import ESM from CJS: use dynamic import (await import("esm-lib")).
  • Avoid module: commonjs unless you’re stuck with old Node/tooling.
    It fights against modern ecosystem defaults.

Let’s focus only on the cases that always fail when mixing CommonJS (CJS) and ES Modules (ESM) in Node.js (no flags, no TypeScript magic, just real runtime rules).

Always-Failing Imports Between CJS and ESM

ImporterImportedFormWhy it Fails
CJS (require)ESMrequire("esm-lib") → try to access named exports directlyYou only ever get a wrapper/default; named exports are not live bindings and cannot be imported this way.
ESM (import)CJSimport { foo } from "cjs-lib"CJS doesn’t have true named exports — everything is under module.exports. ESM sees only the default.
ESM (import)CJSimport * as mod from "cjs-lib" and expect live bindingsYou do get an object, but it’s a frozen snapshot of module.exports, not live bindings → semantic mismatch.
CJS (require)ESMrequire("esm-lib") with top-level await inside ESMCJS has no top-level await; loading such a module fails immediately.
CJS (require)ESMrequire("esm-lib") inside an ESM-only package (no .cjs fallback)Node refuses — “ERR_REQUIRE_ESM” error.

Rule of Thumb

  • CJS importing ESM → only works via dynamic import(), not require.
  • ESM importing CJS → only works via default import, never named imports.

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.