Das Symptom
Du fügst "type": "module" in die package.json ein, weil du endlich import
statt require schreiben willst. Danach startet nichts mehr. Node bricht mit
ERR_MODULE_NOT_FOUND ab und nennt eine Datei, die eindeutig existiert –
./utils, direkt daneben im selben Ordner, unübersehbar.
Der zweite Anlauf, ein Import auf ein Verzeichnis mit index.js darin, endet
ähnlich unfreundlich. Die Datei ist da. Der Pfad stimmt. Node sagt trotzdem
Nein.
Die „Layer 8“-Wahrheit
Node sucht nicht mehr. Das ist die ganze Änderung. Die Doku formuliert es in
einem Satz, der keine Ausnahmen kennt: „A file extension must be provided when
using the import keyword to resolve relative or absolute specifiers. Directory
indexes (e.g. './startup/index.js') must also be fully specified.“ Kein
Anhängen von .js, kein Nachschauen, ob ein Verzeichnis vielleicht eine
index.js enthält. Was du schreibst, ist der Pfad – Punkt.
Was hier eigentlich weh tut, ist nicht der Fehler, sondern der Abschied von
einer Gewohnheit. Jahrelang hat require('./utils') im Hintergrund eine kleine
Rateschleife über Endungen und Verzeichnisse abgearbeitet, und niemand hat
darüber nachgedacht. Bequem war das, billig war es nicht: Jeder Import kostete
mehrere Dateisystemzugriffe, und derselbe Quelltext bedeutete je nach
Verzeichnisinhalt etwas anderes. ESM verzichtet darauf bewusst, denn das
Verhalten „matches how import behaves in browser environments“ – ein Browser
kann nun einmal nicht in einem Verzeichnis nachsehen.
Und dann ist da die eigentliche Falle, die selten benannt wird: Es ist ein
einziges Feld, das entscheidet, in welcher Welt eine .js-Datei lebt.
Dateien mit der Endung .js werden als ES-Module geladen, wenn die
nächstgelegene übergeordnete package.json das Feld "type" mit dem Wert
"module" enthält – fehlt das Feld oder steht dort "commonjs", gelten sie als
CommonJS. Wer dieses Feld setzt, ändert damit die Bedeutung jedes einzelnen
Imports in allen .js-Dateien darunter. Das ist kein Schalter, das ist ein
Umzug.