1. Runtime-level (Node.js)
| Importer \ Imported | CommonJS (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 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"intsconfig.json.
This makes TypeScript align with Node’s dual .cjs / .mjs resolution.
- Works best when
package.jsonhas"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: commonjsunless 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(), notrequire. - ESM importing CJS → only works via default import, never named imports.