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.
Inhaltsverzeichnis
- 1. Warum die Wahl des Output-Formats überhaupt zählt
- 2. ESM und CommonJS: die fundamentalen Unterschiede
- 3. Das module-Feld in tsconfig.json richtig wählen
- 4. moduleResolution: bundler, node16 und nodenext
- 5. Die exports-Map in package.json korrekt aufbauen
- 6. Dual-Package-Builds: beide Formate gleichzeitig ausliefern
- 7. Der Dual-Package-Hazard und wie man ihn vermeidet
- 8. Interop-Fallen: default-Exporte und require() von ESM
- 9. ESM- und CommonJS-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.