Photo by Yosuke Ota on Unsplash

On this page

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" 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.

Search articles

Type a word to search published articles.

Open the search page