⚠️ 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 setting
Emitted output
Interop style
When to use
commonjs
Uses require() + module.exports
CJS default, no static import at runtime
Legacy Node projects, Jest, older tooling
esnext (or es2022)
Keeps import/export as-is
Pure ESM output
Browser builds, bundlers (webpack, Vite), modern Node ≥ 20
nodenext
Hybrid: decides per file (.cjs → CJS, .mjs → ESM, .ts depends on package.json "type")
Matches Node.js resolution rules exactly
Best choice for modern Node.js projects
node16
Like nodenext, but frozen to Node 16 semantics
Legacy compat for Node 16 only
Use only if targeting Node 16
3. TypeScript + Runtime Interop
TS Import Syntax
CJS 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
Importer
Imported
Form
Why it Fails
CJS (require)
ESM
require("esm-lib") → try to access named exports directly
You only ever get a wrapper/default; named exports are not live bindings and cannot be imported this way.
ESM (import)
CJS
import { foo } from "cjs-lib"
CJS doesn’t have true named exports — everything is under module.exports. ESM sees only the default.
ESM (import)
CJS
import * as mod from "cjs-lib" and expect live bindings
You do get an object, but it’s a frozen snapshot of module.exports, not live bindings → semantic mismatch.
CJS (require)
ESM
require("esm-lib") with top-level await inside ESM
CJS has no top-level await; loading such a module fails immediately.
CJS (require)
ESM
require("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.
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.