import oder require: wer entscheidet das eigentlich?
Die Endung und das Feld type in der nächstgelegenen package.json legen fest, wie Node deine Datei liest. Alles Weitere folgt daraus, auch die Fehlermeldungen.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Die Fehlermeldung nennt fast nie die eigentliche Ursache
Vier Meldungen decken den größten Teil der Fälle ab, und alle vier sagen dasselbe: Die Datei wird in einem anderen Format gelesen, als sie geschrieben ist. Cannot use import statement outside a module heißt, dass Node deine Datei als CommonJS liest. Unexpected token 'export' ist dieselbe Sache aus Sicht des Parsers. exports is not defined in ES module scope heißt umgekehrt, dass eine CommonJS-Datei als ESM gelesen wird, meist weil in der package.json type auf module steht und die Endung .cjs fehlt. Und ERR_REQUIRE_ESM bedeutet, dass ein require auf ein reines ESM-Paket trifft.
Weil die Meldungen nach Syntaxfehlern klingen, wird an der falschen Stelle gesucht. Typisch ist die Runde aus Endung ändern, Babel ergänzen, tsconfig anfassen und am Ende ein Werkzeug einbauen, das die Datei vorher umschreibt. Danach läuft es, aber niemand kann mehr sagen, welches Format wo gilt, und der nächste Umbau beginnt wieder von vorn.
Richtig teuer wird die Frage erst an der Paketgrenze. Immer mehr Bibliotheken erscheinen nur noch als ESM, während Testwerkzeuge, ältere Server und interne Pakete auf CommonJS stehen. Wer das Projekt deshalb halb umstellt, hat am Ende beide Formate im selben Prozess, und damit möglicherweise zweimal dasselbe Modul mit zwei getrennten Zuständen.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Die beiden Formate und der Weg dazwischen
Die Gegenüberstellung lohnt sich weniger für die Frage, welches Format besser ist, sondern für die Frage, was dich der Wechsel im Alltag kostet. Genau diese Punkte sind es, an denen bestehende Projekte hängen bleiben.
ES Modules
der Standard der Sprache mit import und export, in Browsern und in Node zu Hause
CommonJS
das gewachsene Node-Format mit require und module.exports, synchron und zur Laufzeit auflösbar
Beides aus einer Quelle
ein Paket, das über das exports-Feld je nach Aufrufer die eine oder die andere Fassung liefert
| Entscheidungsfrage | ES Modules | CommonJS | Beides aus einer Quelle |
|---|---|---|---|
| Wie kommst du an eine Bibliothek, die es nur noch als ESM gibt? | Stärke Ein gewöhnlicher import, mehr ist nicht nötig. Das ist inzwischen der häufigere Fall bei neu veröffentlichten Paketen. | Kommt darauf an Seit Node 22.12 und, auf der Vorgängerlinie, seit Node 20.19 kann require auch ES-Module laden, ohne zusätzliches Flag, allerdings nur, wenn im gesamten Modulgraph kein await auf oberster Ebene steht, sonst kommt ERR_REQUIRE_ASYNC_MODULE. Der ältere und für noch ältere Laufzeiten sichere Weg ist ein dynamisches import(), das den Aufrufer asynchron macht. | Stärke Der Paketautor hat die Frage bereits beantwortet, jeder Aufrufer bekommt über die Bedingungen im exports-Feld die passende Fassung. |
| Kannst du erst zur Laufzeit entscheiden, was geladen wird? | Kommt darauf an import-Anweisungen werden vor der Ausführung aufgelöst und stehen immer ganz oben. Für bedingtes Laden gibt es import(), das ein Promise zurückgibt und damit eine asynchrone Stelle erzeugt. | Stärke require ist ein gewöhnlicher Funktionsaufruf, darf in einem if stehen, und das Ergebnis liegt sofort vor. Für Fallunterscheidungen nach Plattform oder Konfiguration ist das bequem. | Kommt darauf an Es gilt die Regel des Formats, in dem der Aufrufer läuft. Für dich als Paketautor ändert sich dadurch nichts, für deine Nutzer schon. |
| Wie gut entfernt ein Bundler ungenutzten Code? | Stärke Import und Export stehen statisch im Code, deshalb kann das Werkzeug ohne Ausführung feststellen, welcher Export nirgends verwendet wird. | Schwäche module.exports wird zur Laufzeit zusammengesetzt und kann von Bedingungen abhängen. Der Bundler muss im Zweifel alles behalten, deshalb landen ganze Bibliotheken im Build. | Kommt darauf an Nur wenn die Bedingung import auf echtes ESM zeigt. Ein Paket, das unter dieser Bedingung übersetztes CommonJS ausliefert, bringt für das Entfernen ungenutzten Codes nichts. |
| Was passiert bei zirkulären Abhängigkeiten? | Kommt darauf an Die Bindungen sind live, Funktionsdeklarationen stehen bereits vor der Auswertung zur Verfügung. Greift der Code beim Auswerten auf eine noch nicht initialisierte Konstante zu, gibt es einen ReferenceError, also einen sichtbaren Fehler an der richtigen Stelle. | Schwäche Der Zirkel liefert das halbfertige module.exports, meist ein leeres Objekt. Der Fehler zeigt sich später als undefined is not a function, oft in einer ganz anderen Datei. | Kommt darauf an Es gilt die Regel des jeweiligen Formats, zusätzlich kann derselbe Modulzustand doppelt existieren, wenn beide Fassungen im Prozess landen. |
| Wie viel Aufwand steckt in Test- und Bauwerkzeugen? | Schwäche Vite, Vitest, Rollup und esbuild arbeiten von Haus aus damit, in einem gewachsenen Aufbau rund um Jest ist es dagegen die teuerste Ecke: Der ESM-Betrieb läuft dort über die Option experimental-vm-modules, und ein Import lässt sich nachträglich nicht umbiegen, das Ersetzen von Modulen im Test muss also anders gelöst werden als mit require. | Stärke Der über Jahre gewachsene Werkzeugkasten arbeitet ohne Zutun damit, inklusive dem Leeren des Modul-Caches im Test. Dafür fehlen zunehmend Pakete, die es nur noch als ESM gibt. | Schwäche Für jede Kombination aus Werkzeug und Umgebung musst du prüfen, welche Fassung tatsächlich geladen wird. Genau hier entsteht der doppelte Zustand, der später schwer zu finden ist. |
| Was steht dir an Sprachmitteln zur Verfügung? | Stärke await auf oberster Ebene, import.meta.url für den eigenen Pfad, live gehaltene Bindungen und durchgehend strikter Modus ohne eigenes Zutun. | Kommt darauf an __dirname, __filename, require.resolve und ein zugänglicher Modul-Cache, dafür kein await auf oberster Ebene und keine live gehaltenen Bindungen. | Kommt darauf an Du musst dich auf den kleinsten gemeinsamen Nenner beschränken, sonst funktioniert eine der beiden Fassungen nicht. |
Wie kommst du an eine Bibliothek, die es nur noch als ESM gibt?
Ein gewöhnlicher import, mehr ist nicht nötig. Das ist inzwischen der häufigere Fall bei neu veröffentlichten Paketen.
Seit Node 22.12 und, auf der Vorgängerlinie, seit Node 20.19 kann require auch ES-Module laden, ohne zusätzliches Flag, allerdings nur, wenn im gesamten Modulgraph kein await auf oberster Ebene steht, sonst kommt ERR_REQUIRE_ASYNC_MODULE. Der ältere und für noch ältere Laufzeiten sichere Weg ist ein dynamisches import(), das den Aufrufer asynchron macht.
Der Paketautor hat die Frage bereits beantwortet, jeder Aufrufer bekommt über die Bedingungen im exports-Feld die passende Fassung.
Kannst du erst zur Laufzeit entscheiden, was geladen wird?
import-Anweisungen werden vor der Ausführung aufgelöst und stehen immer ganz oben. Für bedingtes Laden gibt es import(), das ein Promise zurückgibt und damit eine asynchrone Stelle erzeugt.
require ist ein gewöhnlicher Funktionsaufruf, darf in einem if stehen, und das Ergebnis liegt sofort vor. Für Fallunterscheidungen nach Plattform oder Konfiguration ist das bequem.
Es gilt die Regel des Formats, in dem der Aufrufer läuft. Für dich als Paketautor ändert sich dadurch nichts, für deine Nutzer schon.
Wie gut entfernt ein Bundler ungenutzten Code?
Import und Export stehen statisch im Code, deshalb kann das Werkzeug ohne Ausführung feststellen, welcher Export nirgends verwendet wird.
module.exports wird zur Laufzeit zusammengesetzt und kann von Bedingungen abhängen. Der Bundler muss im Zweifel alles behalten, deshalb landen ganze Bibliotheken im Build.
Nur wenn die Bedingung import auf echtes ESM zeigt. Ein Paket, das unter dieser Bedingung übersetztes CommonJS ausliefert, bringt für das Entfernen ungenutzten Codes nichts.
Was passiert bei zirkulären Abhängigkeiten?
Die Bindungen sind live, Funktionsdeklarationen stehen bereits vor der Auswertung zur Verfügung. Greift der Code beim Auswerten auf eine noch nicht initialisierte Konstante zu, gibt es einen ReferenceError, also einen sichtbaren Fehler an der richtigen Stelle.
Der Zirkel liefert das halbfertige module.exports, meist ein leeres Objekt. Der Fehler zeigt sich später als undefined is not a function, oft in einer ganz anderen Datei.
Es gilt die Regel des jeweiligen Formats, zusätzlich kann derselbe Modulzustand doppelt existieren, wenn beide Fassungen im Prozess landen.
Wie viel Aufwand steckt in Test- und Bauwerkzeugen?
Vite, Vitest, Rollup und esbuild arbeiten von Haus aus damit, in einem gewachsenen Aufbau rund um Jest ist es dagegen die teuerste Ecke: Der ESM-Betrieb läuft dort über die Option experimental-vm-modules, und ein Import lässt sich nachträglich nicht umbiegen, das Ersetzen von Modulen im Test muss also anders gelöst werden als mit require.
Der über Jahre gewachsene Werkzeugkasten arbeitet ohne Zutun damit, inklusive dem Leeren des Modul-Caches im Test. Dafür fehlen zunehmend Pakete, die es nur noch als ESM gibt.
Für jede Kombination aus Werkzeug und Umgebung musst du prüfen, welche Fassung tatsächlich geladen wird. Genau hier entsteht der doppelte Zustand, der später schwer zu finden ist.
Was steht dir an Sprachmitteln zur Verfügung?
await auf oberster Ebene, import.meta.url für den eigenen Pfad, live gehaltene Bindungen und durchgehend strikter Modus ohne eigenes Zutun.
__dirname, __filename, require.resolve und ein zugänglicher Modul-Cache, dafür kein await auf oberster Ebene und keine live gehaltenen Bindungen.
Du musst dich auf den kleinsten gemeinsamen Nenner beschränken, sonst funktioniert eine der beiden Fassungen nicht.
Was passt wann
- Wenn du ein neues Projekt aufsetzt
- nimm ESM, setz type auf module und schreib in relativen Importen die Dateiendung konsequent mit.
- Wenn eine gewachsene Node-Anwendung stabil läuft und nur ein einzelnes ESM-Paket fehlt
- hol genau dieses Paket über ein dynamisches import(), statt das ganze Projekt umzustellen.
- Wenn du eine Bibliothek veröffentlichst, die andere in beiden Welten einbinden
- beschreib die Einstiegspunkte im exports-Feld und halte Zustand aus den Modulen heraus, damit zwei geladene Fassungen nicht auseinanderlaufen.
Fünf Prüfungen, und das Format steht fest
- 01 Endet die Datei auf .mjs oder .cjs? Dann ist die Frage schon beantwortet.
- 02 Sonst zählt das Feld type in der nächstgelegenen package.json.
- 03 Fehlt dieses Feld, behandelt Node die Datei als CommonJS.
- 04 Im Browser entscheidet allein das Attribut type am script-Element.
- 05 Bundler lesen dieselben Felder und zusätzlich die Bedingungen im exports-Feld.
Was du danach ohne Ausprobieren entscheiden kannst
Die Entscheidung hängt an drei Fragen: Was liest Node in deinem Verzeichnis, was verlangen deine Abhängigkeiten, und wer bindet dein Ergebnis später ein. Sind die beantwortet, ist die Wahl des Formats keine Geschmacksfrage mehr, sondern ergibt sich.
Das geltende Format bestimmen
Die nächstgelegene package.json oberhalb der Datei entscheidet über alle .js-Dateien darunter. Ein type auf module macht sie zu ES-Modulen, ein fehlendes Feld zu CommonJS. Die Endungen .mjs und .cjs stechen diese Regel in beide Richtungen.
Eine einzelne ESM-Abhängigkeit einbinden
In einer CommonJS-Anwendung holst du ein reines ESM-Paket mit einem dynamischen import(), das ein Promise liefert. Das macht nur die aufrufende Funktion asynchron, nicht das ganze Projekt.
Die Umstellung an einer Grenze führen
Ein halb umgestelltes Projekt ist teurer als beide Reinformen. Stell lieber ein abgegrenztes Paket vollständig um und lass den Rest, als überall einzelne Dateien zu wechseln.
Importe vollständig schreiben
In ES-Modulen brauchen relative Importe die Dateiendung, und ein Verzeichnisname wird nicht automatisch zur index-Datei ergänzt. Bundler sind hier nachsichtig, Node ist es nicht, und genau daran scheitert die erste Ausführung außerhalb des Bundlers.
Den Ersatz für __dirname kennen
In ES-Modulen gibt es weder __dirname noch __filename noch require.resolve. Neuere Node-Versionen bieten dafür import.meta.dirname und import.meta.filename, sonst führt der Weg über fileURLToPath(import.meta.url).
Beim Veröffentlichen die Einstiegspunkte festlegen
Das exports-Feld beschreibt, welche Datei ein Aufrufer je nach Format bekommt, und sperrt gleichzeitig alle nicht aufgeführten Unterpfade. Wer beide Formate ausliefert, hält Zustand aus den Modulen heraus, damit zwei geladene Fassungen nicht auseinanderlaufen.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Wer entscheidet, was deine Datei ist
Node beantwortet diese Frage in zwei Schritten. Zuerst die Endung: .mjs ist immer ein ES-Modul, .cjs immer CommonJS. Für .js gilt die nächstgelegene package.json oberhalb der Datei. Steht dort type mit dem Wert module, sind alle .js-Dateien darunter ES-Module, sonst CommonJS. Das Feld wirkt also verzeichnisweise, und ein Unterverzeichnis mit eigener package.json kann eine andere Antwort geben als das Projekt darüber.
Genau das ist der übliche Ausweg für Werkzeugdateien: eine kleine package.json mit type auf commonjs in einem Unterverzeichnis, damit eine alte Konfigurationsdatei weiterläuft, während der Rest bereits umgestellt ist. Sauberer ist die Endung .cjs für einzelne Dateien, weil sie im Dateibaum sofort sichtbar ist.
Bei TypeScript kommt eine zweite Ebene dazu. Was der Compiler ausgibt, hängt an module und moduleResolution, und mit den Endungen .mts und .cts lässt sich dasselbe pro Datei festlegen. Wichtig ist die Unterscheidung: Der Compiler prüft nur, die Laufzeit entscheidet. Wer in der tsconfig auf ESM stellt, aber ohne type auf module ausliefert, bekommt genau dieselben Meldungen wie ohne TypeScript.
Die Richtung, die lange nicht funktioniert hat
Aus einem ES-Modul heraus lässt sich CommonJS schon immer laden. import fs from 'node:fs' liefert dabei module.exports als Standardexport. Benannte Importe funktionieren ebenfalls, aber nur, weil Node die Datei vorher statisch nach Zuweisungen an exports durchsucht. Baut ein Paket seine Exporte in einer Schleife oder hinter einer Bedingung auf, findet diese Analyse nichts, und du musst den Standardexport importieren und die Eigenschaften daraus entnehmen.
Die Gegenrichtung war jahrelang versperrt. Seit Node 22.12 und, auf der Vorgängerlinie, seit Node 20.19 lädt require auch ES-Module ohne zusätzliches Flag, sofern im gesamten Modulgraph kein await auf oberster Ebene vorkommt, andernfalls kommt ERR_REQUIRE_ASYNC_MODULE. Zurück bekommst du das Namensraumobjekt des Moduls, der Standardexport liegt also unter der Eigenschaft default und nicht direkt auf dem Rückgabewert. Wer noch ältere Stände bedienen muss, bleibt beim dynamischen import().
Für den Alltag ergibt sich daraus eine einfache Reihenfolge. Prüf zuerst, ob das Paket überhaupt eine CommonJS-Fassung mitbringt, denn dann erübrigt sich alles Weitere. Wenn nicht, nimm ein dynamisches import() an genau einer Stelle und reich das Ergebnis weiter, statt es an zwanzig Stellen zu wiederholen. Und wenn beides nicht geht, ist das ein Argument für die Umstellung des betroffenen Pakets, nicht für einen Umbau des gesamten Projekts.
Was sich am eigenen Code ändert
Der erste Unterschied betrifft die Pfade. In ES-Modulen sind Dateiendungen in relativen Importen Pflicht, und ein Import auf ein Verzeichnis wird nicht automatisch zur index-Datei ergänzt. Bundler sind an dieser Stelle großzügig, weshalb der Fehler oft erst auftritt, wenn dasselbe Modul zum ersten Mal ohne Bundler läuft, etwa in einem Testlauf oder in einem Skript.
Der zweite Unterschied betrifft die Umgebung des Moduls. __dirname, __filename, require und require.resolve gibt es nicht, dafür import.meta.url und in neueren Node-Versionen import.meta.dirname sowie import.meta.filename. this ist auf oberster Ebene undefined statt module.exports, und der strikte Modus gilt immer. JSON-Dateien lassen sich nicht mehr einfach importieren, sondern brauchen ein Importattribut in der Form with { type: 'json' }.
Der dritte Unterschied ist der subtilste: ES-Module halten ihre Bindungen live. Exportiert ein Modul eine Zählvariable und erhöht sie später, sehen alle Importeure den neuen Wert. In CommonJS bekommst du beim require eine Momentaufnahme des Objekts, und eine später neu zugewiesene Variable erreicht dich nicht mehr. Genau daran scheitern Umstellungen, bei denen ein Modul einen veränderlichen Wert exportiert statt einer Funktion, die ihn liefert.
Das exports-Feld und der doppelte Zustand
Das exports-Feld einer package.json beschreibt, welche Datei ein Aufrufer bekommt, und zwar getrennt nach Bedingungen: import für ES-Module, require für CommonJS, types für die Typdeklarationen, default als Auffangfall. Die Reihenfolge ist bedeutsam, denn die erste passende Bedingung gewinnt. types gehört deshalb nach vorn, default immer ans Ende.
Das Feld hat eine zweite, oft überraschende Wirkung: Sobald es existiert, sind alle nicht aufgeführten Unterpfade gesperrt. Ein Import auf paket/lib/hilfsmittel.js scheitert dann mit Package subpath is not defined by exports, obwohl die Datei im Verzeichnis liegt. Das ist der häufigste Grund dafür, dass ein Update ohne erkennbare Änderung am Code plötzlich nichts mehr findet.
Wer beide Formate ausliefert, handelt sich den doppelten Zustand ein. Landen im selben Prozess sowohl die ESM- als auch die CommonJS-Fassung eines Pakets, existiert jede Klasse zweimal. Ein instanceof schlägt dann fehl, obwohl das Objekt aussieht wie erwartet, und ein Modul, das intern eine Registrierung oder einen Zwischenspeicher hält, führt zwei getrennte davon. Die praktikablen Auswege: nur ein Format ausliefern, oder den Zustand in ein eigenes CommonJS-Paket legen, das beide Fassungen gemeinsam nutzen.
Im Browser gelten eigene Regeln
Ein script-Element mit type="module" ist automatisch verzögert, läuft also erst nach dem Aufbau des Dokuments, hat einen eigenen Gültigkeitsbereich und ist immer strikt. Ein zweites Skript sieht die Variablen des ersten nicht, was im Vergleich zu klassischen Skripten regelmäßig überrascht.
Der Browser kennt außerdem keine bloßen Paketnamen. import { z } from 'lodash-es' funktioniert nur, wenn ein Bundler den Namen vorher in einen Pfad übersetzt oder wenn eine Import Map im Dokument die Zuordnung festlegt. Ohne beides bekommst du eine Meldung über einen ungültigen Specifier, und daran ändert auch ein laufender Entwicklungsserver nichts, der intern genau diese Übersetzung vornimmt.
Und schließlich gelten die Regeln der gleichen Herkunft: Module werden mit CORS geladen, ein Modul von einer fremden Adresse braucht also die passenden Header. Für das gezielte Nachladen von Programmteilen ist import() im Browser dasselbe Werkzeug wie in Node, nur dass es dort zusätzlich die Grenze für ein eigenes Bündel markiert.
Dazu passende Kurse
Wie sich das im Bestand umstellen lässt, ohne den Betrieb anzuhalten, ist Thema der JavaScript-Kurse bei cmt, dort geht es unter anderem um import gegen require im laufenden Projekt .
Weil der TypeScript-Compiler mitentscheidet, welches Format am Ende ausgeliefert wird, gehören diese Einstellungen in Kurse zu Typen, Konfiguration und Ausgabeformaten .
Wie sicher bist du beim Thema wirklich?
Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.
Wo du genau das übst
Ein sehr gutes, praxisorientiertes und nachhaltiges Seminar. So sollte es immer sein. Vielen Dank.
Die Schulung ist gerade für Einsteiger oder Entwickler*innen mit eingestaubtem Basiswissen sehr hilfreich.
Ich bin sehr glücklich Franz als Trainer gehabt zu haben. Er hat offensichtlich unglaublich viel Wissen zu den Themen, und schafft es, dieses auch interaktiv und verständlich weiterzugeben.
Häufige Fragen
Muss ich die Endung .js in Importen wirklich mitschreiben?
Ich schreibe TypeScript. Betrifft mich das überhaupt?
Was ist der schnellste Weg, ein ESM-Paket in eine bestehende CommonJS-Anwendung zu holen?
Warum finde ich nach einem Update Unterpfade eines Pakets nicht mehr?
Deine Ansprechpartner
Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.
Yves Hoppe
Weiterbildung & Beratung
Hilft dir, aus dem Webentwicklung-Programm den passenden Kurs für deinen Stand zu finden.
Norbert Jansen
Beratung & Inhouse
Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.
Module, die in jeder Umgebung dasselbe bedeuten
In den JavaScript- und Node-Kursen bei cmt stellst du ein bestehendes Projekt Schritt für Schritt um und siehst dabei, an welchen Stellen die beiden Formate tatsächlich auseinandergehen.