ESM vs. CommonJS: Output-Konfiguration für TypeScript-Pakete
AI generated
<T>
type
TypeScript · ESM · CommonJS · Build-Konfiguration
ESM vs. CommonJS in TypeScript
Output-Konfiguration ohne Laufzeit-Überraschungen

Ein falsch gesetztes module-Feld in tsconfig.json oder eine unvollständige exports-Map in package.json führt bei TypeScript-Paketen regelmäßig zu ERR_REQUIRE_ESM- oder ERR_UNKNOWN_FILE_EXTENSION-Fehlern, die erst beim Konsumenten sichtbar werden. Wer die Unterschiede zwischen ESM und CommonJS beim Kompilieren versteht, vermeidet den Dual-Package-Hazard und liefert Pakete, die in beiden Welten zuverlässig funktionieren.

17 Min. Lesezeit module · moduleResolution · exports-Map · Dual-Package TypeScript 5.x · Node.js 20/22

1. Warum die Wahl des Output-Formats überhaupt zählt

Ein TypeScript-Paket, das für die falsche Zielumgebung kompiliert wurde, funktioniert lokal beim Autor einwandfrei und bricht erst beim Konsumenten mit einer kryptischen Fehlermeldung ab. Der Compiler selbst prüft zur Kompilierzeit keine Laufzeit-Kompatibilität zwischen dem gewählten Output-Format und der Umgebung, in der das Paket später importiert wird. Erst Node.js oder ein Bundler entscheidet zur Laufzeit, ob eine Datei als ESM oder als CommonJS interpretiert wird, und genau an dieser Schnittstelle entstehen die meisten Probleme.

Besonders bei Bibliotheken, die sowohl in modernen ESM-Projekten als auch in älteren CommonJS-Codebasen eingesetzt werden sollen, reicht ein einziges Output-Format oft nicht aus. Ein require()-Aufruf auf ein reines ESM-Paket schlägt mit ERR_REQUIRE_ESM fehl, während ein import-Statement auf eine falsch deklarierte CommonJS-Datei mit ERR_UNKNOWN_FILE_EXTENSION abbricht. Die richtige Konfiguration von module, moduleResolution und der exports-Map in package.json entscheidet darüber, ob ein TypeScript-Paket in beiden Ökosystemen zuverlässig funktioniert oder zur Support-Falle wird.

2. ESM und CommonJS: die fundamentalen Unterschiede

CommonJS ist das ursprüngliche Modulsystem von Node.js: Module werden synchron mit require() geladen, Exporte laufen über das module.exports-Objekt, und die Auflösung von Modulpfaden geschieht zur Laufzeit. ESM (ECMAScript Modules) ist der offizielle Standard von JavaScript selbst, verwendet die statische import/export-Syntax, wird asynchron geladen und erlaubt dem Engine bereits vor der Ausführung, den vollständigen Abhängigkeitsgraphen zu analysieren, was unter anderem zuverlässigeres Tree Shaking ermöglicht.

Ein zentraler technischer Unterschied betrifft this auf Modulebene und Top-Level-await: In CommonJS ist this auf Modulebene ein leeres Objekt, in ESM ist es undefined. ESM-Module erlauben await direkt auf oberster Ebene, ohne dass eine umschließende async-Funktion nötig ist, während CommonJS-Module das nicht unterstützen. Node.js erkennt das Format primär über die Dateiendung (.mjs für ESM, .cjs für CommonJS) oder über das "type"-Feld in package.json, das für .js-Dateien ohne explizite Endung als Default gilt.

3. Das module-Feld in tsconfig.json richtig wählen

Das module-Feld in tsconfig.json bestimmt, in welches JavaScript-Modulformat TypeScript importierte und exportierte Anweisungen übersetzt. "module": "CommonJS" übersetzt import/export in require()/module.exports-Aufrufe und ist die richtige Wahl für Node.js-Projekte, die weiterhin als CommonJS ausgeliefert werden sollen. "module": "ESNext" oder "module": "ES2022" lässt import/export-Syntax unverändert im Output, was für Bundler wie Webpack oder für native ESM-Umgebungen gedacht ist.

Seit TypeScript 5.0 gibt es zusätzlich "module": "Node16" und "module": "NodeNext", die das Output-Format nicht global festlegen, sondern pro Datei anhand der Dateiendung und des "type"-Felds in package.json entscheiden, exakt wie Node.js selbst es zur Laufzeit tut. Diese beiden Optionen sind die einzigen, die tatsächlich das komplexe Zusammenspiel aus .mts/.cts-Dateiendungen, dem type-Feld und der exports-Map korrekt abbilden, weshalb sie für jedes neue Paket mit gemischten oder unklaren Zielformaten die richtige Wahl sind.


{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "declaration": true,
    "outDir": "dist/esm",
    "rootDir": "src"
  }
}

4. moduleResolution: bundler, node16 und nodenext

Während module das Ausgabeformat bestimmt, steuert moduleResolution, nach welchem Algorithmus TypeScript Importpfade zur Kompilierzeit auflöst, also welche Datei ein import './utils' tatsächlich referenziert. "moduleResolution": "bundler", eingeführt in TypeScript 5.0, imitiert das Auflösungsverhalten moderner Bundler wie Vite oder esbuild, die Dateiendungen bei relativen Imports oft nicht zwingend verlangen, während Node.js selbst bei ESM-Imports die vollständige Dateiendung inklusive .js erfordert.

Diese scheinbare Kleinigkeit ist eine der häufigsten Stolperfallen: Ein TypeScript-Projekt mit "moduleResolution": "bundler" kompiliert klaglos import { helper } from './utils' ohne Endung, aber sobald das kompilierte Ergebnis direkt mit Node.js ausgeführt wird (ohne Bundler dazwischen), schlägt der Import fehl, weil Node.js bei ESM zwingend ./utils.js erwartet. Für Pakete, die als reine Node.js-Bibliothek ohne Bundler-Zwischenschritt konsumiert werden, ist deshalb "moduleResolution": "NodeNext" zusammen mit expliziten .js-Endungen in den eigenen Imports die robustere Wahl, auch wenn die Quelldateien selbst .ts heißen.


// WRONG with moduleResolution: NodeNext — fails at runtime under plain Node.js
import { helper } from './utils';

// RIGHT — explicit .js extension, even though the source file is utils.ts
import { helper } from './utils.js';

// tsconfig.json excerpt for a pure Node.js library without a bundler step
// {
//   "compilerOptions": {
//     "module": "NodeNext",
//     "moduleResolution": "NodeNext"
//   }
// }

5. Die exports-Map in package.json korrekt aufbauen

Die exports-Map in package.json ist seit Node.js 12 der moderne, bevorzugte Weg, um zu definieren, welche Dateien eines Pakets nach außen sichtbar sind und über welchen Pfad Konsumenten sie importieren dürfen. Für TypeScript-Pakete mit Dual-Format-Unterstützung enthält jeder Eintrag typischerweise vier Schlüssel: types für die .d.ts-Datei, import für den ESM-Einstiegspunkt, require für den CommonJS-Einstiegspunkt und optional default als Fallback. Die Reihenfolge dieser Schlüssel ist dabei nicht kosmetisch: types muss als erster Schlüssel stehen, da TypeScript-Tools die Map von oben nach unten auswerten und sonst den falschen Typ auflösen können.

Ein häufiger Fehler ist, im Hauptfeld main weiterhin nur auf die CommonJS-Datei zu zeigen, während die exports-Map bereits ein ESM-Format vorsieht. Ältere Tools, die die exports-Map noch nicht respektieren, greifen dann auf ein inkonsistentes main-Feld zurück. Die robuste Lösung ist, main, module und exports synchron zu pflegen oder, wo möglich, ausschließlich über die exports-Map zu arbeiten und ältere Tools bewusst als nicht unterstützt zu behandeln.


{
  "name": "@mironsoft/data-utils",
  "version": "3.2.0",
  "type": "module",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "types": "./dist/esm/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/esm/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/esm/index.js"
    },
    "./package.json": "./package.json"
  }
}

6. Dual-Package-Builds: beide Formate gleichzeitig ausliefern

Ein Dual-Package-Build kompiliert dieselbe TypeScript-Quelle zweimal mit unterschiedlichen tsconfig-Profilen, einmal mit "module": "ESNext" für den ESM-Output und einmal mit "module": "CommonJS" für den CommonJS-Output. Beide Ausgaben landen in getrennten Verzeichnissen, üblicherweise dist/esm und dist/cjs, wobei die CommonJS-Dateien mit der Endung .cjs versehen werden, damit Node.js sie unabhängig vom umgebenden "type"-Feld korrekt als CommonJS erkennt.

Der zweite tsconfig-Build für CommonJS erfordert typischerweise ein eigenes package.json innerhalb von dist/cjs mit dem Inhalt { "type": "commonjs" }, damit Node.js beim Traversieren des Verzeichnisbaums die richtige Interpretation wählt, selbst wenn das Wurzel-package.json "type": "module" deklariert. Build-Tools wie tsup oder unbuild automatisieren genau diesen Dual-Build-Prozess inklusive der korrekten Dateiendungen und der zusätzlichen package.json-Marker, was das manuelle Pflegen von zwei separaten tsconfig-Dateien in vielen Projekten überflüssig macht.


#!/usr/bin/env bash
set -euo pipefail

# Manual dual-build without a bundler tool

# ESM build
npx tsc -p tsconfig.esm.json

# CommonJS build
npx tsc -p tsconfig.cjs.json

# Mark CommonJS output explicitly so Node.js does not
# reinterpret .js files there as ESM via the root "type" field
echo '{"type":"commonjs"}' > dist/cjs/package.json

# Rename .js to .cjs for unambiguous resolution
find dist/cjs -name '*.js' -exec sh -c 'mv "$1" "${1%.js}.cjs"' _ {} \;

7. Der Dual-Package-Hazard und wie man ihn vermeidet

Der Dual-Package-Hazard beschreibt ein subtiles Problem: Wenn eine Anwendung dasselbe Paket einmal über require() (CommonJS-Build) und einmal über import (ESM-Build) lädt, etwa weil zwei unterschiedliche Abhängigkeiten es jeweils unterschiedlich referenzieren, erhält die Anwendung zwei getrennte Instanzen desselben Moduls mit jeweils eigenem internen Zustand. Bei zustandsbehafteten Modulen wie Singletons, Caches oder Registries führt das zu Bugs, die extrem schwer zu reproduzieren sind, weil beide Codepfade für sich genommen korrekt funktionieren, aber nicht denselben Zustand teilen.

Die wirksamste Vorbeugung ist, zustandsbehaftete Logik in TypeScript-Paketen zu vermeiden oder, wo unvermeidbar, den Zustand explizit über globalThis zu teilen statt ihn im Modul-Scope zu halten. Für reine Utility-Bibliotheken ohne internen Zustand ist der Dual-Package-Hazard meist harmlos, da beide Instanzen unabhängig funktionsfähig bleiben. Bibliotheksautoren sollten diesen Unterschied in der Dokumentation explizit machen, damit Konsumenten wissen, ob ihr Paket sicher in gemischten ESM/CommonJS-Umgebungen mehrfach geladen werden kann.

8. Interop-Fallen: default-Exporte und require() von ESM

Eine der häufigsten Fehlerquellen beim Zusammenspiel von ESM und CommonJS in TypeScript betrifft export default. Kompiliert man eine Datei mit export default function foo() {} nach CommonJS, landet die Funktion nicht direkt in module.exports, sondern in module.exports.default, sofern esModuleInterop nicht aktiviert ist. Ohne dieses Flag müsste ein CommonJS-Konsument explizit require('./foo').default schreiben, was für die meisten Teams unintuitiv und fehleranfällig ist.

"esModuleInterop": true in Verbindung mit "allowSyntheticDefaultImports": true löst dieses Problem, indem TypeScript beim Kompilieren automatisch eine Interop-Hilfsfunktion einfügt, die einen Default-Export korrekt aus einem CommonJS-Modul extrahiert und umgekehrt. Seit Node.js 22 ist zusätzlich das experimentelle Feature möglich, ESM-Module direkt mit require() zu laden, sofern das Modul module.exports keine unaufgelösten Top-Level-await-Aufrufe enthält, was die Interop-Situation langfristig entschärft, aber für heute produktive TypeScript-Pakete noch nicht als alleinige Lösung ausreicht.


// source.ts
export default function createLogger() {
  return { log: (msg: string) => console.log(msg) };
}

// WITHOUT esModuleInterop, compiled to CommonJS:
// const createLogger = require('./source').default; // must add .default

// WITH "esModuleInterop": true, TypeScript inserts an interop helper
// so consumers can write the intuitive form instead:
import createLogger from './source';
const logger = createLogger();

9. ESM- und CommonJS-Strategien im Vergleich

Je nach Zielgruppe eines TypeScript-Pakets ist eine andere Output-Strategie sinnvoll. Die folgende Übersicht vergleicht die gängigsten Ansätze.

Strategie Kompatibilität Build-Aufwand Empfehlung
Nur CommonJS Überall lauffähig Niedrig Interne Node-Tools, keine Bundler-Nutzer
Nur ESM Kein require() möglich Niedrig Neue Projekte ohne CommonJS-Altlasten
Dual-Package (ESM + CJS) Beide Welten Hoch ohne Tooling Öffentliche npm-Pakete
Dual-Package mit tsup/unbuild Beide Welten Niedrig durch Automatisierung Empfohlener Standard für Bibliotheken
module: NodeNext ohne Dual-Build Ein Format, korrekt aufgelöst Niedrig Interne Monorepo-Pakete

Für interne Tools und Monorepo-Pakete ohne externe Konsumenten reicht meist ein einziges Format mit korrekt konfiguriertem module: NodeNext. Öffentliche npm-Pakete profitieren dagegen fast immer von einem Dual-Package-Build, idealerweise automatisiert über tsup oder unbuild, um den manuellen Konfigurationsaufwand und die Fehleranfälligkeit einer handgepflegten exports-Map gering zu halten.

Mironsoft

TypeScript-Paketarchitektur, Build-Konfiguration und npm-Publishing

Kein ERR_REQUIRE_ESM mehr bei euren Konsumenten?

Wir konfigurieren module, moduleResolution und die exports-Map für eure TypeScript-Pakete, richten zuverlässige Dual-Package-Builds ein und beseitigen Interop-Fallen zwischen ESM und CommonJS.

Package-Audit

Prüfung von exports-Map, main/module-Feldern und tsconfig auf Inkonsistenzen

Dual-Build-Setup

Automatisierter ESM- und CommonJS-Build mit korrekten Dateiendungen

Migrationsberatung

Schrittweiser Umstieg bestehender CommonJS-Pakete auf ESM-Unterstützung

10. Zusammenfassung

Die Wahl zwischen ESM und CommonJS in TypeScript ist keine reine Geschmacksfrage, sondern hat direkte Auswirkungen darauf, ob ein Paket bei Konsumenten zuverlässig funktioniert. "module": "NodeNext" zusammen mit "moduleResolution": "NodeNext" ist die robusteste Grundeinstellung, weil sie dasselbe Auflösungsverhalten wie Node.js zur Laufzeit abbildet. Für öffentliche Bibliotheken führt an einem Dual-Package-Build mit sauberer exports-Map kein Weg vorbei, idealerweise automatisiert über Tools wie tsup.

Der Dual-Package-Hazard und Interop-Fallen bei export default sind die beiden häufigsten Ursachen für schwer reproduzierbare Bugs an der Schnittstelle zwischen ESM und CommonJS. Mit esModuleInterop, korrekt gesetzten Dateiendungen und einer konsequent gepflegten exports-Map lassen sich beide Probleme systematisch vermeiden, statt sie bei jedem neuen Konsumenten erneut zu debuggen.

ESM vs. CommonJS in TypeScript — Das Wichtigste auf einen Blick

module: NodeNext

Bildet das tatsächliche Auflösungsverhalten von Node.js pro Datei ab, statt ein globales Format zu erzwingen.

exports-Map

types, import, require und default in dieser Reihenfolge, synchron zu main und module gepflegt.

Dual-Package-Hazard

Zwei geladene Instanzen desselben Moduls bei gemischtem require()/import. Zustand vermeiden oder global teilen.

esModuleInterop

Löst die default-Export-Falle zwischen CommonJS und ESM automatisch beim Kompilieren.

11. FAQ: ESM vs. CommonJS in TypeScript

1Unterschied module vs. moduleResolution?
module bestimmt das Ausgabeformat, moduleResolution die Auflösung von Importpfaden zur Kompilierzeit.
2Warum types zuerst in exports?
Tools werten die Map von oben nach unten aus, sonst wird der falsche Typ aufgelöst.
3Was ist der Dual-Package-Hazard?
Zwei getrennte Modulinstanzen mit eigenem Zustand bei gemischtem require() und import derselben Abhängigkeit.
4Warum ERR_REQUIRE_ESM?
require() lädt synchron und kann reine ESM-Module ohne CommonJS-Variante nicht direkt einbinden.
5Wozu package.json in dist/cjs?
type: commonjs stellt korrekte Interpretation sicher, unabhängig vom Wurzel-package.json.
6Löst esModuleInterop alles?
Löst die häufigste default-Export-Falle, deckt aber nicht jeden Sonderfall ab.
7Reicht bundler-Resolution für npm-Pakete?
Nein, für reine Node-Bibliotheken ohne Bundler ist NodeNext robuster.
8require() lädt ESM seit Node 22?
Experimentell ja, aber noch keine alleinige Kompatibilitätsstrategie für produktive Pakete.
9Welche Tools automatisieren Dual-Builds?
tsup und unbuild erzeugen ESM- und CommonJS-Output samt exports-Map automatisch.
10Hazard bei jedem Paket relevant?
Nein, nur bei Modulen mit geteiltem internem Zustand wie Singletons oder Caches.