ESM/CJS Interop: Probleme zwischen Modulsystemen loesen
AI generated
JS
() =>
JavaScript · Node.js · Module Systems
ESM/CJS Interop: Probleme zwischen Modulsystemen loesen
von default Exports bis zur Dual-Package-Falle

ESM und CommonJS sind zwei grundverschiedene Modulsysteme, die in derselben Node-Laufzeit koexistieren muessen, und genau daraus entsteht die ESM/CJS Interop Problematik. Wer den Unterschied zwischen require() und import nicht versteht, stolpert frueher oder spaeter ueber ERR_REQUIRE_ESM, verlorene Named Exports oder eine defekte instanceof Pruefung durch doppelt geladene Abhaengigkeiten.

18 Min. Lesezeit exports-Feld · require() · dual package · cjs-module-lexer Node.js 18+ · Node.js 22+

1. Warum ESM und CJS nicht einfach kompatibel sind

CommonJS wurde vor der Standardisierung von ES Modules entwickelt und loest Abhaengigkeiten synchron zur Laufzeit ueber require(), wobei jedes Modul ein einziges module.exports-Objekt zurueckgibt. ES Modules hingegen bauen einen statischen Modulgraphen bereits vor der Ausfuehrung auf, import und export sind syntaktisch fest verdrahtet und lassen sich nicht bedingt zur Laufzeit aufrufen. Diese ESM/CJS Interop Problematik entsteht genau an dieser Grenze: zwei Systeme mit unterschiedlicher Ladephilosophie muessen im selben Prozess zusammenarbeiten.

Node.js hat beide Systeme parallel implementiert, aber die Bruecke zwischen ihnen ist keine vollstaendig transparente Uebersetzung. Ein CJS-Modul, das per import aus ESM geladen wird, erscheint als ein einziges Default-Export-Objekt, waehrend ein ESM-Modul, das aus CJS per require() geladen werden soll, historisch komplett scheiterte, weil require() synchron arbeitet und ESM-Auswertung asynchron ist. Die ESM/CJS Interop Regeln, die Node dafuer definiert, sind pragmatisch, aber an mehreren Stellen ueberraschend fuer alle, die sie zum ersten Mal treffen.

Wer Bibliotheken schreibt, die sowohl von CJS- als auch von ESM-Konsumenten genutzt werden sollen, muss diese ESM/CJS Interop Regeln aktiv verstehen, nicht nur zufaellig richtig treffen. Falsch konfigurierte Pakete fuehren zu Fehlern, die erst beim Konsumenten auftauchen, oft mit kryptischen Fehlermeldungen wie ERR_REQUIRE_ESM oder fehlenden Named Exports, die im Editor eigentlich sichtbar waren.

2. module.exports vs. export default: was beim Import passiert

In CommonJS ist module.exports ein einziges veraenderbares Objekt, dem beliebige Eigenschaften zugewiesen werden koennen, waehrend exports.foo = bar lediglich eine Kurzform ist, die auf dasselbe Objekt zeigt. In ES Modules gibt es dagegen klar getrennte Named Exports und genau einen optionalen Default Export pro Modul, beide werden zur statischen Analysezeit erfasst, nicht zur Laufzeit zusammengebaut. Genau diese strukturelle Differenz ist der Kern jedes ESM/CJS Interop Problems.

Wenn ESM ein CJS-Modul importiert, behandelt Node das komplette module.exports-Objekt als Default Export. Named Exports fuer CJS-Module werden zusaetzlich ueber statische Analyse synthetisiert, dazu gleich mehr im Abschnitt zum cjs-module-lexer. Der haeufigste Fehler an dieser Stelle: Entwickler erwarten, dass export default in ESM genauso funktioniert wie module.exports = in CJS, dabei sind es zwei unterschiedliche Konzepte, die nur oberflaechlich aehnlich aussehen.


// legacy-utils.cjs — CommonJS module
function formatPrice(cents) {
  return (cents / 100).toFixed(2);
}
function parsePrice(str) {
  return Math.round(parseFloat(str) * 100);
}

module.exports = { formatPrice, parsePrice };
// Equivalent shorthand form:
// exports.formatPrice = formatPrice;
// exports.parsePrice = parsePrice;

// consumer.mjs — ES module importing the CJS file above
import utils from "./legacy-utils.cjs";
// The whole module.exports object arrives as the default export
console.log(utils.formatPrice(1999)); // "19.99"

// Node can ALSO synthesize named exports via static analysis:
import { formatPrice, parsePrice } from "./legacy-utils.cjs";
console.log(formatPrice(500)); // "5.00"

Die Named-Import-Variante funktioniert nur, weil Node den CJS-Quelltext statisch nach erkennbaren Zuweisungsmustern durchsucht. Komplexere Zuweisungen, etwa module.exports = computeExports() mit dynamischer Berechnung, lassen sich nicht statisch analysieren, dort bleibt nur der Default-Import-Weg als verlaessliche Option fuer ESM/CJS Interop.

3. package.json: type, das exports Feld und Dual Packages

Das Feld "type" in package.json legt fest, wie Node .js-Dateien standardmaessig interpretiert: "type": "module" behandelt .js als ESM, "type": "commonjs" oder das Fehlen des Feldes behandelt .js als CJS. Unabhaengig davon werden .mjs-Dateien immer als ESM und .cjs-Dateien immer als CJS interpretiert, das ist der zuverlaessigste Weg, das Modulsystem einer Datei eindeutig zu markieren, ganz ohne Ruecksicht auf das type-Feld.

Das exports-Feld ist der zentrale Baustein fuer sauberes ESM/CJS Interop in Paketen, die von beiden Seiten konsumiert werden sollen. Es erlaubt, unterschiedliche Einstiegspunkte fuer require und import anzugeben, sogenannte Conditional Exports. Damit kann ein Paket sowohl eine CJS- als auch eine ESM-Variante ausliefern, ohne dass Konsumenten selbst herausfinden muessen, welche Datei zu ihrem Modulsystem passt.


{
  "name": "@mironsoft/price-utils",
  "version": "2.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./package.json": "./package.json"
  }
}

Der "main"-Schluessel bleibt als Fallback fuer sehr alte Tooling-Versionen erhalten, die das exports-Feld noch nicht kennen, wird aber von modernem Node und modernen Bundlern ignoriert, sobald exports vorhanden ist. Das fuehrt zu einer klassischen Dual-Package-Falle: ein Paket liefert zwei separate Dateien fuer dasselbe Modul aus, eine fuer CJS und eine fuer ESM. Wird dieselbe Bibliothek versehentlich sowohl ueber require als auch ueber import in denselben Prozess geladen, entstehen zwei getrennte Modulinstanzen mit eigenem State, was instanceof-Pruefungen und Singleton-Patterns unbemerkt bricht.

4. require() aus ESM: das neue synchrone Verhalten in Node 22

Lange Zeit war es in Node unmoeglich, ein ESM-Modul per require() aus CJS zu laden, weil ESM-Auswertung grundsaetzlich asynchron ist, waehrend require() synchron zurueckkehren muss. Seit Node 22 mit einer experimentellen Flag und ab Node 23 standardmaessig aktiviert kann require() tatsaechlich synchrones ESM laden, solange das Zielmodul keine Top-Level-Await-Ausdruecke enthaelt, die eine echte asynchrone Pause im Modulgraphen erzwingen wuerden.

Dieses Feature loest ein sehr praktisches ESM/CJS Interop Problem: Legacy-Codebasen, die komplett auf CJS aufgebaut sind, aber eine moderne, ESM-only Bibliothek einbinden wollen, mussten frueher entweder komplett auf ESM migrieren oder eine dynamische import()-Bruecke mit asynchroner API bauen. Mit synchronem require() von ESM entfaellt dieser Migrationsdruck fuer viele Projekte, zumindest fuer Abhaengigkeiten ohne Top-Level-Await.


// legacy-app.cjs — a CommonJS codebase
// Node 22+ (with --experimental-require-module) or Node 23+ by default:
const { chalk } = require("chalk"); // chalk v5 is ESM-only

console.log(chalk.green("This works synchronously now"));

// Still fails if the target module has top-level await:
// const mod = require("./has-top-level-await.mjs");
// -> ERR_REQUIRE_ASYNC_MODULE

// The traditional async bridge, still valid everywhere:
async function loadEsmDependency() {
  const { default: esmOnlyLib } = await import("esm-only-lib");
  return esmOnlyLib;
}

Wichtig fuer Bibliotheksautoren: dieses Verhalten ist an die Node-Version des Konsumenten gebunden, nicht an die eigene. Wer eine Bibliothek fuer ein breites Publikum schreibt, kann sich nicht darauf verlassen, dass alle Nutzer bereits Node 22 oder 23 einsetzen, und sollte weiterhin ueber das exports-Feld eine echte CJS-Variante anbieten, statt sich ausschliesslich auf dieses neuere ESM/CJS Interop Feature zu verlassen.

5. import von CJS aus: Default-Interop und das esModule-Flag

Beim umgekehrten Weg, ESM importiert CJS, gibt es eine weitere Feinheit, die haeufig fuer Verwirrung sorgt: Babel und TypeScript kennzeichnen von ihnen transpilierte ESM-Module mit einer Eigenschaft __esModule: true auf dem exportierten Objekt. Tools, die diese Konvention kennen, etwa Bundler mit interop-Helper-Funktionen, verhalten sich dann anders als die native Node-Runtime, die dieses Flag ignoriert und immer das komplette module.exports-Objekt als Default nimmt.

Das fuehrt zu einer bekannten Klasse von Bugs: ein mit Babel kompiliertes Modul, das eigentlich export default MyClass verwendet hat, landet nach der Kompilierung als { __esModule: true, default: MyClass }. Wird dieses kompilierte Ergebnis nativ per require() aus echtem CJS-Code geladen, muss man explizit auf .default zugreifen, waehrend ein Bundler mit interop-Helper automatisch entpackt. Diese Diskrepanz zwischen Bundler-Verhalten und nativer Node-Runtime ist eine der haeufigsten Quellen fuer ESM/CJS Interop Verwirrung in gemischten Toolchains.


// Compiled output from a Babel/TS build with __esModule marker
// dist/logger.js (CommonJS output, but originally written as ESM)
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = class Logger {
  log(msg) { console.log(`[LOG] ${msg}`); }
};

// Native Node require() sees the raw module.exports object:
const loggerModule = require("./dist/logger.js");
const Logger = loggerModule.default; // must access .default explicitly
new Logger().log("native require needs .default");

// A bundler with interop helper (e.g. Webpack's __esModule check)
// would let you write:
// import Logger from "./dist/logger.js"; // works transparently

6. Conditional Exports fuer echtes Dual Publishing

Conditional Exports im exports-Feld sind die robusteste Loesung fuer ESM/CJS Interop, weil sie die Entscheidung, welche Datei geladen wird, an die Node-Runtime selbst delegieren, statt sie dem Konsumenten oder einem Bundler zu ueberlassen. Die Bedingungen "import" und "require" greifen exakt danach, mit welcher Syntax das Paket geladen wurde, unabhaengig vom "type"-Feld des ladenden Pakets.

Fuer Bibliotheksautoren bedeutet das: der Build-Prozess muss zwei getrennte Ausgaben erzeugen, typischerweise ueber zwei Build-Targets in einem Tool wie tsup oder unbuild, eine .mjs-Datei mit echten ES-Module-Exporten und eine .cjs-Datei mit klassischem module.exports. Beide Dateien muessen funktional identisches Verhalten liefern, sonst bekommen CJS- und ESM-Konsumenten desselben Pakets unterschiedliche Bugs, was in der Praxis extrem schwer zu debuggen ist, weil das Problem nur bei einer Teilmenge der Nutzer auftritt.

7. Named Exports aus CJS: statische Analyse per cjs-module-lexer

Wie bereits im Abschnitt zu Default-Exports angedeutet, synthetisiert Node beim Import eines CJS-Moduls aus ESM automatisch Named Exports, sofern diese statisch erkennbar sind. Verantwortlich dafuer ist der interne cjs-module-lexer, ein spezialisierter, extrem schneller Parser, der CJS-Quelltext nach Mustern wie exports.foo = ..., module.exports.foo = ... oder Object.defineProperty(exports, "foo", ...) durchsucht, ohne den Code tatsaechlich auszufuehren.

Die Grenzen dieser statischen Analyse sind fuer ESM/CJS Interop praktisch relevant: dynamisch berechnete Export-Namen, bedingte Zuweisungen in Schleifen oder Re-Exports ueber Spread-Operatoren auf module.exports werden vom Lexer haeufig nicht zuverlaessig erkannt. In solchen Faellen bleibt nur der Default-Import als garantiert funktionierender Weg, auch wenn die intuitive Named-Import-Syntax eleganter aussehen wuerde.


// analyzable.cjs — cjs-module-lexer CAN detect these patterns
exports.add = (a, b) => a + b;
module.exports.subtract = (a, b) => a - b;
Object.defineProperty(exports, "multiply", { value: (a, b) => a * b });

// NOT reliably analyzable — falls back to default-only import
const ops = { divide: (a, b) => a / b };
module.exports = { ...ops, extra: computeDynamically() };

// Safe consumption regardless of analyzability:
import cjsModule from "./analyzable.cjs";
const { add, subtract, multiply } = cjsModule; // always works

8. Haeufige Fehler: ERR_REQUIRE_ESM und die Dual-Package-Falle

Der Fehler ERR_REQUIRE_ESM tritt auf, wenn versucht wird, ein reines ESM-Modul ueber klassisches require() zu laden, ohne dass die Runtime das neuere synchrone ESM-Loading unterstuetzt oder aktiviert hat. Die typische Ursache: eine Abhaengigkeit hat in einem Major-Update von CJS auf ESM-only umgestellt, ein sehr verbreitetes Muster im Ökosystem der letzten Jahre, und der eigene Code nutzt noch require() statt import(). Die Loesung ist entweder das eigene Projekt auf ESM zu migrieren, die neuere require()-ESM-Faehigkeit ab Node 22/23 zu nutzen, oder auf eine aeltere, noch CJS-kompatible Paketversion zu bleiben.

Die Dual-Package-Falle ist subtiler und schwerer zu diagnostizieren: wenn ein Paket sowohl unter require als auch unter import im selben Prozess geladen wird, etwa weil eine Abhaengigkeit intern require nutzt, waehrend die Anwendung import verwendet, entstehen zwei komplett getrennte Modulinstanzen. Fuer Bibliotheken mit internem State, etwa Caches, Registries oder Singleton-Patterns, fuehrt das zu Bugs, bei denen Daten scheinbar verschwinden, weil sie in der jeweils anderen Instanz landen. Dieses ESM/CJS Interop Problem laesst sich nur durch sorgfaeltiges Paket-Design vermeiden, insbesondere durch Vermeidung von Modul-lokalem mutable State in Bibliotheken, die dual publiziert werden.

9. ESM und CJS im direkten Vergleich

Die folgende Uebersicht fasst die wichtigsten Verhaltensunterschiede zusammen, die bei ESM/CJS Interop in der Praxis am haeufigsten zu Problemen fuehren.

Merkmal CommonJS ES Modules Interop-Konsequenz
Ladeverhalten Synchron, zur Laufzeit Statischer Graph, teils asynchron require() von ESM historisch unmoeglich
Export-Struktur Ein veraenderbares Objekt Named + ein Default Export CJS-Import wird zu Default-Objekt in ESM
this im Modul-Scope module.exports undefined Manche CJS-Patterns brechen unter ESM
__dirname/__filename Verfuegbar Nicht verfuegbar import.meta.url als ESM-Ersatz noetig
Zirkulaere Abhaengigkeiten Teilweise ausgefuellte Objekte Live Bindings, konsistenter ESM loest viele klassische CJS-Zirkelbugs

Diese Tabelle zeigt: die meisten ESM/CJS Interop Probleme entstehen nicht durch Fehler in Node selbst, sondern durch grundverschiedene Designentscheidungen, die beide Systeme zu ihrer jeweiligen Entstehungszeit fuer sinnvoll hielten. Wer diese Unterschiede kennt, kann Interop-Bugs meist schon beim Schreiben des Codes vermeiden, statt sie erst beim Konsumenten zu entdecken.

Mironsoft

Node.js Modernisierung und Paket-Architektur

ERR_REQUIRE_ESM in eurer CI, bevor der Kunde es merkt?

Wir analysieren eure Abhaengigkeiten auf ESM/CJS-Bruchstellen, richten Dual-Package-Builds mit Conditional Exports ein und begleiten die schrittweise Migration eurer Codebasis auf ES Modules.

Dependency-Audit

Erkennung von ESM-only Abhaengigkeiten und potenziellen Dual-Package-Fallen

Package-Setup

exports Feld, Conditional Exports und Dual Builds fuer eigene npm-Pakete

Migration

Schrittweise Umstellung von CommonJS auf ES Modules ohne Ausfallzeit

10. Zusammenfassung

ESM/CJS Interop entsteht, weil zwei grundverschiedene Modulsysteme im selben Node-Prozess koexistieren muessen: CommonJS mit synchronem require() und einem einzigen veraenderbaren Export-Objekt, ES Modules mit statischem Modulgraph und klar getrennten Named und Default Exports. Das exports-Feld in package.json mit Conditional Exports ist der zuverlaessigste Weg, ein Paket fuer beide Konsumenten korrekt auszuliefern, waehrend der cjs-module-lexer Named Exports aus CJS nur bei statisch erkennbaren Mustern synthetisieren kann.

Die haeufigsten Fehlerquellen sind ERR_REQUIRE_ESM bei veralteten Importmustern und die Dual-Package-Falle bei Bibliotheken mit internem State. Seit Node 22/23 erlaubt synchrones require() von ESM ohne Top-Level-Await zusaetzliche Flexibilitaet, ersetzt aber nicht die Notwendigkeit, ESM/CJS Interop beim Paket-Design von Anfang an mitzudenken, statt es erst beim ersten Bugreport eines Konsumenten zu entdecken.

ESM/CJS Interop — Das Wichtigste auf einen Blick

Import von CJS aus ESM

Das komplette module.exports-Objekt wird zum Default Export, Named Exports nur bei statisch erkennbaren Mustern.

exports-Feld

Conditional Exports mit import/require entscheiden robust, welche Datei geladen wird, unabhaengig vom type-Feld.

require() von ESM

Seit Node 22/23 synchron moeglich, aber nicht bei Top-Level-Await und abhaengig von der Node-Version des Konsumenten.

Dual-Package-Falle

Zwei Modulinstanzen bei gemischtem require/import fuer dasselbe Paket brechen State-abhaengige Bibliotheken.

11. FAQ: ESM/CJS Interop

1Was bedeutet ESM/CJS Interop?
Die Regeln, nach denen ES Modules und CommonJS gegenseitig geladen werden koennen, trotz grundverschiedener Lade- und Export-Modelle.
2ESM importiert CJS, was passiert?
module.exports wird zum Default Export. Named Exports werden zusaetzlich per statischer Analyse synthetisiert, wenn erkennbar.
3Kann require() ESM laden?
Seit Node 22/23 ja, ausser bei Top-Level-Await. Fuer breite Kompatibilitaet bleibt die async import()-Bruecke sicherer.
4Was ist die Dual-Package-Falle?
Gemischtes require/import desselben Pakets erzeugt zwei Modulinstanzen mit eigenem State, das bricht Singleton-Patterns.
5Wozu das exports-Feld?
Definiert Conditional Exports fuer import/require, damit ein Paket beide Konsumenten korrekt bedient, ohne manuelle Auswahl.
6Warum funktionieren Named Imports nicht immer?
Der cjs-module-lexer erkennt nur statische Zuweisungsmuster, dynamische Exports fallen durch, dann hilft nur der Default-Import.
7Was ist ERR_REQUIRE_ESM?
Tritt auf, wenn require() ein reines ESM-Modul laden soll, ohne synchrones ESM-Loading. Meist eine ESM-only gewordene Abhaengigkeit.
8Was macht __esModule?
Markiert Babel/TS-transpilierte ESM-Module. Native Node ignoriert es, Bundler mit Interop-Helper entpacken damit den Default Export.
9Reines ESM oder Dual Package?
Dual Package mit Conditional Exports ist sicherer bei breiter Kompatibilitaet, reines ESM spart Build-Komplexitaet bei modernen Zielgruppen.
10Wie Dual-Package-Bugs vermeiden?
Modul-lokalen mutable State vermeiden, Zustand explizit uebergeben, und in Tests pruefen, ob require/import dieselbe Instanz liefern.