Startseite / Artikel / Zehn alltägliche JavaScript-Regeln, die Ihr mentales Modell stören.

Zehn alltägliche JavaScript-Regeln, die Ihr mentales Modell stören.

Er untersucht zehn subtile Verhaltensweisen von JavaScript – von der Mutierbarkeit von const über Closures bis hin zur asynchronen Fehlerbehandlung –, die heimlich Fehler in der Codebasis erfahrener Entwickler verursachen.

4076 Wörter

Sobald man sich mit der Syntax von JavaScript vertraut gemacht hat, wirkt es nicht mehr bedrohlich. Man stolpert nicht mehr über fehlende Semikolle, asynchrone Callbacks erscheinen als Alltagssache, und der Unterschied zwischen let und const wird zur Selbstverständlichkeit. Nachdem man genügend Projekte abgeschlossen hat, fühlt sich die Sprache wie ein alter Freund an. Man erkennt häufige Fehler auf den ersten Blick, hat eine gute Intuition dafür, wie Promises abgearbeitet werden, und weiß, dass null und undefined nicht dasselbe sind – egal wie lässig manche APIs sie miteinander verwechseln.

Aber Vertrautheit beseitigt nicht alle Überraschungen – sie verändert lediglich ihre Form. Die Verwirrung auf Anfängerebene wird durch subtilere, gefährlichere Annahmen ersetzt. Man weiß, dass Objekte per Referenz übergeben werden, vergisst aber dennoch, dass eine flache Kopie dazu führt, dass verschachtelte Daten gemeinsam genutzt werden. Man kennt die Abhängigkeit von Promises von Microtasks, schätzt aber weiterhin die Reihenfolge falsch ein, wenn mehrere Warteschlangen miteinander interagieren. Man weiß um die Existenz von Zwangsvorgängen, überseht aber weiterhin die stille Umwandlung, die in einem auf den ersten Blick unschuldig wirkenden Vergleich, einer Sortierfunktion oder einem Eigenschaftsabruf verborgen ist.

Die schwierigsten Aspekte von JavaScript sind selten exotische Randfälle oder sprachliche Kuriositäten. Es handelt sich dabei um gewöhnliche Regeln, die auf unerwartete Weise miteinander kombiniert werden. Jede einzelne Zeile erscheint in Ordnung, liest sich gut und würde einer oberflächlichen Überprüfung standhalten. Die Überraschung entsteht dadurch, wie der Laufzeitumgebung diese Zeilen miteinander verknüpft – auf eine Weise, die nicht mit dem mentalen Modell übereinstimmt, das man beim Lesen des Codes hatte.

Die zehn unten aufgeführten Verhaltensweisen führen bei erfahrenen Teams weiterhin zu Problemen, genau weil sie sich in Code verbergen, der völlig normal aussieht.

1. const schützt die Bindung, nicht das Objekt

Eine gängige Abkürzung zur Erklärung von const lautet, es „erschaffe einen Wert, der sich nicht ändern kann“. Das ist eine gute Vereinfachung für Primitive, funktioniert aber nicht bei Arrays und Objekten. Was const tatsächlich garantiert, ist, dass die Variable nicht neu zugewiesen werden kann, um auf etwas anderes zu verweisen. Es sagt nichts darüber aus, ob das Objekt, auf das sie verweist, verändert werden kann.

Ein mit const deklariertes user-Objekt kann weiterhin neue Eigenschaften erhalten. Sein eingebettetes Settings-Objekt kann weiterhin aktualisiert werden. Ein mit const deklariertes Array kann weiterhin Elemente hinzugefügt, sortiert oder geleert werden. Aus Sicht von JavaScript berührt das nichts an der Bindung – die Variable verweist weiterhin auf genau dasselbe Objekt wie zuvor.

Dieser Unterschied zwischen Erwartung und Realität verursacht weiterhin echte Fehler in Codebasen, die stark auf dem Konzept der Unveränderlichkeit beruhen. Ein Konfigurationsobjekt wird als Konstante importiert, anschließend ändert ein Modul sie heimlich – plötzlich sehen alle anderen Module, die auf diese Referenz verweisen, die Änderung. Ein in dem Anwendungsstatus gespeichertes Array wird vor Ort sortiert, wodurch sowohl der „aktuelle“ Wert als auch ein älterer Wert verändert werden, von dem ein anderer Teil des Codes annahm, es handele sich um einen festgehaltenen historischen Zustand. Ein Test ändert ein gemeinsam genutztes Fixtures-Objekt, wodurch ein völlig unabhängiger Test anfängt, fehlerhaft zu laufen – allerdings nur dann, wenn der Testlauf-Tool zufällig die Tests in einer anderen Reihenfolge ausführt.

Das Schlüsselwort const vermittelt ein trügerisches Gefühl der Sicherheit; es sieht aus wie eine schützende Hülle um den Wert, doch die Laufzeitumgebung schützt lediglich den Zeiger auf die Variable, niemals jedoch die Struktur, auf die dieser Zeiger verweist.

Falls Sie tatsächlich Unveränderlichkeit benötigen, müssen Sie diese selbst implementieren. Das kann bedeuten, dass stattdessen völlig neue Objekte zurückgegeben werden anstelle der Bearbeitung vorhandener Objekte, Object.freeze auf die wichtigen Werte angewendet wird, eine Bibliothek verwendet wird, die auf unveränderlichem Zustand basiert, oder dass Ihre Funktionen so entworfen werden, dass sie von vornherein keine Referenzen auf interne, veränderliche Daten herausgeben. Selbst Object.freeze sichert nur die oberste Ebene ab – es sei denn, auch alle verschachtelten Objekte werden eingefroren; die inneren Ebenen bleiben genauso veränderlich wie zuvor.

Die meisten erfahrenen Entwickler können diese Regel aus dem Gedächtnis aufsagen, doch die Überraschung tritt jedes Mal wieder auf, wenn der visuelle Stil des Codes stärkere Garantien andeutet, als JavaScript tatsächlich bietet.

2. Objektspreizen erstellt weniger Kopien, als es scheint

Das Verbreiten eines Objekts mit { ...obj } ist zu einem der gängigen JavaScript-Idiome geworden. Es liest sich klar, eignet sich hervorragend zum Zusammenführen von Standardwerten mit Überschreibungen und ermöglicht es, eine modifizierte Version eines Wertes zu erstellen, ohne das ursprüngliche Objekt anzufassen.

Visuell sieht es wie eine vollständige, unabhängige Kopie aus. In Wirklichkeit reicht die Kopie nur bis auf eine Ebene tief.

const original = {
  profile: {
    name: "Umar",
    skills: ["JavaScript", "Node.js"],
  },
};

const copy = { ...original };
copy.profile.skills.push("TypeScript");

Sobald dieser Codeausschnitt ausgeführt wird, erscheint die neu hinzugefügte Fähigkeit auch in original.profile.skills. Das Objekt auf höchster Ebene ist zwar neu, doch das darin eingebettete profile-Objekt sowie der darin eingebettete skills-Array sind weiterhin genau die gleichen Objekte, auf die das ursprüngliche Objekt zeigte.

Das ist es, was diesen Fehler so hartnäckig macht: Die erste Schicht verhält sich genau so, wie man es erwarten würde. Die Überprüfung von copy !== original gibt true zurück, was den Eindruck erweckt, man habe eine saubere Trennung erreicht. Das Problem der gemeinsamen Mutation zeigt sich erst, wenn man noch einen Schritt tiefer geht.

Auch die Verbreitung führt zu einer weiteren Überraschung, wenn sie zur Zusammenführung von Konfigurationsobjekten verwendet wird. Das Schreiben von { ...defaults, ...options } ersetzt ganze verschachtelte Objekte, anstatt ihre einzelnen Schlüssel zu kombinieren. Wenn options eine Einstellung innerhalb eines verschachtelten Objekts überschreibt, verschwindet jede damit zusammengehörige Einstellung aus defaults, obwohl der Aufrufer diese ursprünglich gar nicht berühren wollte.

Die Lösung besteht nicht automatisch darin, „alles tief zu klonen“. Ein vollständiger tiefer Klon kann kostspielig sein, die Objektidentität entfernen, von der man an anderer Stelle tatsächlich abhängt, und Daten duplizieren, die eigentlich geteilt bleiben sollten. Entscheidend ist vielmehr herauszufinden, welche spezifischen verschachtelten Pfade unabhängige Kopien benötigen. Behandeln Sie diese ausdrücklich, wenden Sie ein geeignetes Werkzeug für unveränderliche Aktualisierungen an oder strukturieren Sie tief verschachtelte Daten so, dass die Eigentumsgrenzen auf den ersten Blick sichtbar sind.

Object Spread funktioniert genau so, wie es angekündigt wird, wenn man tatsächlich nur eine flache Kopie möchte. Sobald seine kurze Syntax fälschlicherweise für einen vollständigen, tiefen Klon gehalten wird, wird sie zu einem Nachteil.

3. Gewöhnliche Gleichheit kann mehrere Umwandlungen verbergen

Die erfahrensten Entwickler verwenden standardmäßig ===, um den mit == verbundenen Problemen bei der Zwangskonvertierung aus dem Weg zu gehen. Diese Gewohnheit ist tatsächlich nützlich, schützt sie jedoch nicht vor allen impliziten Konvertierungen, die JavaScript durchführt – viele weitere treten an Stellen auf, die nichts mit dem Gleichheitsoperator zu tun haben.

Schlüssel von Objekt-eigenschaften sind ein gutes Beispiel. Mit Ausnahme von Symbolen ist jeder Objektschlüssel im Grunde genommen eine Zeichenkette. Somit greifen sowohl die Zuweisung object[1] als auch das Spüren von object["1"] auf dieselbe Eigenschaft zu. Code, der numerische und zeichenkettenbasierte Identifikatoren mental als zwei getrennte Kategorien betrachtet, kann hier überrascht werden.

Relationale Vergleiche führen je nach verglichenen Werten eigene Umwandlungen durch. Zwei Zeichenketten werden lexikalisch verglichen (Zeichen für Zeichen), während ein Vergleich eines Zahlenwerts mit einer numerischen Zeichenkette stattdessen einen numerischen Vergleich auslösen kann. Deshalb ergibt "20" < "100" false, während 20 < "100" true ergibt. Ändert sich die Herkunft eines Wertes, kann sich das Verhalten von Sortier- oder Validierungslogiken ändern, obwohl die angezeigten Werte identisch aussehen.

Der +-Operator ist besonders schwierig, da er sowohl für numerische Addition als auch für die Zeichenkettenverknüpfung verwendet werden kann, und JavaScript entscheidet je nach Kontext, welchen Mechanismus es anwendet. Eine einzige Zeichenkette, die früh in einer Kette von Additionen auftaucht, kann die Interpretation aller nachfolgenden Operationen verändern. Da Werte aus Formulareingaben, URL-Abfragemparametern und anderen HTML-Quellen in der Regel als Zeichenketten übermittelt werden, kann eine Ausdrucksweise, die mit internen numerischen Werten einwandfrei funktionierte, im Moment des Verbindens mit Benutzereingaben stumm auf die Zeichenkettenverknüpfung umschalten.

Erfahrene Teams vermeiden diese Fallen, indem sie Werte direkt an der Grenze normalisieren, anstatt den Operatoren zu vertrauen, dass sie Rohdaten im Echtzeitmodus korrekt interpretieren. Ein Identifikator wird von Anfang an eindeutig als Zeichenkette oder Zahl festgelegt. Ein Geldbetrag wird in einen validierten numerischen Typ umgewandelt. Ein Datum wird vor jeder Vergleichserstellung in einen geeigneten zeitlichen Typ oder eine feste ISO-Zeichenkette umgewandelt.

Die Zwangsumwandlung an sich ist nicht das eigentliche Problem – JavaScripts Regeln dazu sind gut definiert und konsistent. Das eigentliche Problem ist, dass diese Regeln weiterhin in Bereichen angewandt werden, in denen im Code visuell nichts darauf hindeutet, dass eine Umwandlung überhaupt stattfindet.

4. Array.prototype.sort ordnet vor Ort um und vergleicht standardmäßig Texte

Sortieren scheint eine der einfachsten Operationen zu sein, die auf einem Array durchgeführt werden können, doch es kombiniert heimlich zwei Verhaltensweisen, die viele Menschen in Verwirrung bringen.

Die erste Überraschung ist, dass dabei eine Mutation stattfindet. Durch Aufruf von .sort() wird das Array, auf dem die Methode angewendet wurde, umgeordnet und es wird eine Referenz auf genau dieses gleiche Array zurückgegeben. Es ist leicht, den Rückgabewert in einer neuen Variable zu speichern und anzunehmen, das ursprüngliche Array behalte weiterhin seine ursprüngliche Reihenfolge – nur um festzustellen, dass beide Referenzen nun auf dieselbe, umgeordnete Liste verweisen.

Die zweite Überraschung betrifft die Funktionsweise der Vergleiche, wenn kein Vergleichsfunktion angegeben wird. In diesem Fall wandelt JavaScript jedes Element in einen String um und ordnet sie lexikalisch. Wenn man dies mit einem Array von Zahlen ausprobiert, kann man am Ende etwas wie 1, 100, 20, 3 erhalten anstelle einer aufsteigenden numerischen Reihenfolge.

Diese Kombination wird im Rahmen der State-Verwaltung am Frontend gefährlich. Stellen Sie sich ein Komponente vor, das unmittelbar vor der Darstellung einen Array sortiert, ohne zu erkennen, dass es sich dabei um eine gemeinsame Referenz auf in Cache gespeicherte oder vom Server bereitgestellte Daten handelt. Dadurch wird die gemeinsame Quelle verändert. Eine Schwesterkomponente, die dieselben zugrunde liegenden Daten liest, sieht dann ebenfalls die neue Reihenfolge – ohne selbst jemals eine Sortierfunktion aufrufen zu müssen. Auch die Logik zur Änderungserkennung und Memoisierung kann hier fehlschlagen, da sich die Identität des Arrays (seine Referenz) zwar nie änderte, obwohl sich sein Inhalt geändert hat – weshalb eine oberflächliche Vergleich keine Unterschiede erkennen wird.

Modernes JavaScript bietet nicht-mutierende Alternativen: toSorted() ist in Umgebungen verfügbar, die es unterstützen, und das Klonen des Arrays vor der Sortierung bleibt die gängige Workaround-Lösung, wo es nicht verfügbar ist. Für numerische Sortierung muss man .sort() weiterhin einen expliziten Vergleicher übergeben, der den tatsächlich gewünschten Vergleich kodiert.

Die wichtigste Erkenntnis ist, dass der Name einer Methode nicht ihren vollständigen Funktionsumfang verrät. „Sortieren“ beschreibt das Endergebnis, sagt aber nichts darüber aus, ob die Methode in-place mutiert, wie Werte für den Vergleich umgewandelt werden, welche Stabilitätsgarantien sie bietet oder welche domänenspezifische Sortierung benötigt wird. Selbst erfahrene Entwickler machen Fehler, wenn eine Methode so alltäglich erscheint, dass sie nicht innehalten, um zu überprüfen, was sie eigentlich im Hintergrund tut.

5. Ein Date-Objekt modelliert einen einzigen Moment – die meisten Eingaben lassen sich nicht eindeutig darauf zuordnen

Zeitzonenprobleme in JavaScript liegen nicht wirklich daran, dass Zeitzonen konzeptionell schwierig sind. Es geht vielmehr darum, dass eine kurze Zeichenkette weitaus mehr implizite Annahmen enthält, als es auf den ersten Blick scheint.

Im Grunde genommen ist ein Date-Objekt ein Zeitstempel: ein präziser Moment, der relativ zu UTC gemessen wird. Doch die meisten Daten, mit denen Menschen tatsächlich arbeiten – ein Geburtstag, ein Fälligkeitsdatum, ein Abrechnungszyklus, ein terminiertes Treffen – sind Kalenderkonzepte und keine festen Punkte in der Universalzeit; zudem beziehen sie sich je nach Fall unterschiedlich auf Zeitzonen.

Falls man einen rein datenbasierten String parsen und ihn anschließend mit der örtlichen Zeit darstellen lässt, kann das vom Benutzer gesehene Kalenderdatum je nach Standort variieren. Ein Wert, der „3. August“ darstellen soll, könnte als Mitternacht UTC interpretiert werden, wodurch eine lokale Darstellung an einem anderen Ort dann den 2. August anzeigt. Ebenso kann ein Zeitstempel, der ohne expliziten UTC-Offset erstellt wurde, je nach genauer Stringformatierung und der zur Verarbeitung verwendeten Methode unterschiedlich interpretiert werden.

Die Sommerzeit bringt weitere Komplikationen mit sich. Das Hinzufügen einer festen Anzahl an Millisekunden zu einem Date-Objekt garantiert nicht, dass dies dem Hinzufügen eines Kalendertages in der örtlichen Zeit entspricht – an manchen Tagen in Zeitzonen, in denen die Uhren umgestellt werden, dauern sie tatsächlich 23 oder 25 Stunden.

Erfahrene Entwickler stoßen immer noch darauf, weil ihr Code auf einen einzigen Date-Typ angewiesen ist, um gleichzeitig mehrere unverwandte Konzepte darzustellen. Im Typensystem gibt es keine Angabe darüber, ob ein bestimmter Wert als präziser Zeitpunkt, als reiner Kalendertag oder als Ortszeit für die Interpretation in einer bestimmten Region gedacht ist.

Robuste Systeme lösen dieses Problem, indem sie die Absicht explizit statt implizit darstellen. Zeitpunkte enthalten einen UTC-Offset oder werden direkt in UTC ausgedrückt. Werte, die nur Kalenderdaten enthalten, bleiben weiterhin rein kalendertypisch und werden nicht unnötig in vollständige Zeitstempel umgewandelt. regionsspezifische Pläne behalten den Zeitzonenkontext bei, mit dem sie erstellt wurden. Die Parsung und Formatierung finden an klar definierten Grenzen im System statt, nicht dort, wo der Datum zufällig angezeigt oder gelesen wird.

Daher liegt die Überraschung normalerweise nicht darin, dass Zeitzonen eine Rolle spielen – sondern darin, dass bereits eine auf den ersten Blick unbedeutende Umrechnung stillschweigend entschieden hat, welche Zeitzone verwendet werden soll.

6. Ein Promise kann bereits vor dem Ausführen seines Callbacks abgewickelt werden

Ein bereits abgewickeltes Promise wirkt wie eine erledigte Aufgabe, doch die Funktion, die über .then daran gebunden ist – oder der Code nach einem await – wird nicht mitten im aktuellen synchronen Code ausgeführt. Stattdessen wird sie in die Microtask-Warteschlange eingereiht und danach ausgeführt.

Diese Warteschlange erzeugt eine Abfolge, die erfahrene Entwickler dennoch überraschen kann, sobald Promises, Timer, Event-Handler und reiner synchroner Code miteinander vermischt werden. Ein bereits abgeschlossenes Promise plant seine Fortsetzung auf später, während der aktuell ausgeführte synchrone Code ununterbrochen weiterläuft. Diese in der Warteschlange stehende Fortsetzung wird in der Regel vor jedem für die nächste Aufgabe geplanten Timer-Callback ausgeführt – selbst bei einem setTimeout mit einer Verzögerung von null Millisekunden.

Die eigentliche praktische Gefahr liegt nicht darin, die Reihenfolge der Ausgabe in Konsole-Log-Ämtern bei einem Quiz zu erraten. Vielmehr geht es darum zu verstehen, dass „die Promise gelöst wurde“ und „ihr Handler tatsächlich ausgeführt wurde“ zwei unterschiedliche Zeitpunkte sind. Ein Zustandsupdate, das über einen Promise-Handler abgewickelt wird, ist möglicherweise noch nicht für Code sichtbar, der später in derselben synchronen Ausführungskette läuft. Ein Test könnte einen Wert überprüfen, bevor die ausstehenden Microtasks die Gelegenheit hatten, ausgeführt zu werden. Zudem kann eine ausreichend lange Kette von verschachtelten Microtasks Timer und Rendering weiter zurückdrängen, als erwartet – schließlich verbraucht die Laufzeit immer zunächst die gesamte Microtask-Warteschlange, bevor sie zu etwas anderem übergeht.

Der Code wird anfällig, sobald er von dieser zufälligen Reihenfolge statt von einer expliziten Abfolge abhängt. Deshalb bevorzugen erfahrene Entwickler es, Promises zurückzugeben und abzuwarten, wenn tatsächlich ein Schritt dem anderen folgen muss, nutzen die von ihrem Framework bereitgestellten Lebenszyklus-Hooks und vermeiden es, einen Timer mit Null-Zeitverzögerung als zuverlässige Methode zur Synchronisierung mit anderen Aufgaben zu betrachten.

Der Event-Loop selbst verhält sich konsequent – es ist die Syntax, die mehrere getrennte Zeitlinien so erscheinen lässt, als wären sie enger miteinander verbunden, als es tatsächlich der Fall ist. Ein Promise kann bereits seinen endgültigen Wert enthalten, während der Rest der Anwendung dieser Tatsache noch nicht auf die Spur gekommen ist.

7. Das Einhüllen einer async-Aufruf in try/catch garantiert nicht, dass etwas gefangen wird

Ein try/catch-Block, der eine Funktionsaufruf umschließt, scheint diesen Aufruf vor Fehlern schützen zu sollen. Bei asynchronen Funktionen hängt es jedoch vollständig davon ab, ob man auf die zurückgegebene Promise wartet, ob dieser Schutz tatsächlich wirkt.

try {
  saveAuditLog(record);
} catch (error) {
  reportError(error);
}

Falls saveAuditLog synchron einen Fehler auslöst, bevor es überhaupt eine Promise zurückgibt, wird der Catch-Block dies problemlos handhaben. Wenn saveAuditLog jedoch eine asynchrone Funktion ist, die später fehlschlägt, wurde zu diesem Zeitpunkt bereits eine Promise zurückgegeben und die Ausführung hat den try-Block bereits verlassen. Der Fehler liegt nun in dieser Promise – er hat nichts mehr mit dem bereits abgeschlossenen synchronen Aufruf zu tun.

Durch Hinzufügen von await wird die Ablehnung erneut mit dem umschließenden try/catch-Block verknüpft, funktioniert aber nur, wenn die von Ihnen geschriebene Funktion selbst das Verhalten „await“ unterstützt. Das Weiterleiten der Promise in eine andere Kette kann diese Fehlerbeziehung ebenfalls beibehalten. Einfaches Aufrufen einer asynchronen Funktion innerhalb von eingebettetem Fehlerbehandlungscode, ohne darauf zu warten oder sie zurückzugeben, bewirkt beides nicht.

Dieser Fehler tritt ständig in Ereignishandlern, Array-Callback-Funktionen sowie Bibliotheks-Hooks auf, wo die zugrundeliegende API oft keine Ahnung hat, was sie mit einer von einem asynchronen Callback zurückgegebenen Promise anfangen soll – und ignoriert sie häufig einfach. Der Callback lehnt weiterhin korrekt ab, doch es gibt nichts, das darauf reagiert. Je nach Laufzeit kann dies als Warnung wegen einer unverarbeiteten Ablehnung, als protokolliertes Fehlermeldung, durch einen abgestürzten Prozess oder dadurch sichtbar werden, dass die Aktion stillschweigend nie berücksichtigt wird.

Entwickler, die durch solche Fehler bei der Verwendung von Promises statt von Code-Indentation Schaden erlitten haben, fragen sich, welcher Scope tatsächlich auf die asynchrone Ausführung wartet, wo eine Ablehnung in etwas Handlungsbares umgewandelt wird und ob es noch Wege gibt, bei absichtlich ignorierten Aufrufen Fehler zu melden.

Asynchrone Fehler bewegen sich entlang von Promise-Ketten – unabhängig davon, wie tief die geschweiften Klammern verschachtelt sind.

8. Standardwerte bei Destructuring greifen nur für undefined

Standardwerte bei Destructuring wirken wie eine praktische Schutzmaßnahme gegen fehlende Eingaben.

const { timeout = 5000 } = options;

Der Standardwert wird nur dann angewendet, wenn timeout undefined ist oder im Objekt einfach fehlt. Er wird nicht für null, 0, eine leere Zeichenkette oder jeden anderen ausdrücklich bereitgestellten Wert angewendet.

Oft ist das genau das Verhalten, das man sich wünscht. Null kann eine bewusste Methode sein, einen Verzögerungseffekt auszuschalten, und null kann eine eigene, klare Bedeutung haben. Probleme entstehen, wenn jemand annimmt, dass der Standardwert eine Allzwecklösung für alles „unbrauchbare“ darstellt. Eine API könnte null zurücksenden, und der nachgeschaltete Code versucht dann, damit Rechenoperationen durchzuführen. Ein Formfeld könnte als leere Zeichenkette gesendet werden, sodass der Standardwert niemals ausgelöst wird. Ein Konfigurationslader könnte sorgfältig zwischen „Variable ist nicht gesetzt“ und „Variable wurde explizit auf leer gesetzt“ unterscheiden, während der Code, der diese Konfiguration verarbeitet, beide Fälle gleich behandelt und auf den Standardwert zurückgreift.

Die Standardparameter von Funktionen folgen derselben Logik: Wenn eine Funktion ohne Argument aufgerufen wird, gilt der Standardwert, doch wenn explizit null übergeben wird, gilt dieser nicht. Dieser Unterschied ist von großer Bedeutung, wenn Daten über JSON-Payloads, Datensatzzeilen, Formulareingaben sowie APIs Dritter übertragen werden – in all diesen Fällen tritt null regelmäßig auf.

Entwickler, die sich auf dieses Muster verlassen, neigen dazu, Standardwerte und Validierung als getrennte Aspekte zu behandeln. Ein Standardwert beantwortet die Frage „Was passiert, wenn nichts übergeben wurde?“, während Validierung die Frage beantwortet „Ist das Übergebene tatsächlich akzeptabel?“. Beide Aufgaben in einer einzigen, praktischen Syntax zusammenzufassen kann dazu führen, dass fehlerhafte Daten so aussehen, als wären sie ordnungsgemäß initialisiert worden.

Die Regel dieser Sprache ist hier genau und konsistent. Die Verwirrung entsteht ausschließlich durch das Wort „default“, das eine breitere Abdeckung andeutet, als das strikte Verhalten, das nur undefined zulässt, tatsächlich bietet.

9. Eine fehlende Eigenschaft, eine gelöschte Eigenschaft und undefined sind drei verschiedene Dinge

JavaScript erlaubt es einer Objekt Eigenschaft, den Wert undefined zu haben, oder sie kann auf dem Objekt einfach gar nicht existieren. Beides ergibt bei direktem Auslesen wieder undefined, wodurch es so aussieht, als wären sie austauschbar – doch das sind sie nicht.

Tools wie der in-Operator, Object.hasOwn, Object.keys, die Spread-Syntax, Iteration, Schema-Validatoren sowie die JSON-Serialisierung können alle den Unterschied feststellen. JSON.stringify entfernt beispielsweise Eigenschaften, deren Wert undefined ist, völlig, während ein Array-Element mit undefined wieder anders behandelt wird. Das Zusammenführen von Objekten kann außerdem einen einwandfrei funktionierenden bestehenden Wert durch undefined überschreiben, selbst wenn derjenige, der das Zusammenführen durchführt, nur beabsichtigt hat, dieses Feld unverändert zu lassen.

Dieser Unterschied ist eine häufige Ursache für subtile Aktualisierungsfehler. Ein Backend-PATCH-Payload könnte ein weggelassenes Feld als „unberührt lassen“ und ein explizit auf null gesetztes Feld als „löschen“ interpretieren. Ein Frontend-Formular hingegen könnte für jedes Feld, das der Benutzer nie berührt hat, undefined erzeugen – doch wenn diese Formulardaten in ein Aktualisierungsobjekt übernommen werden, werden diese undefined-Eigenschaften dennoch eingefügt und überschreiben dabei die vorhandenen Werte während des Merges.

Um dies zu vermeiden, sollten Sie die Semantik der Aktualisierung absichtlich definieren und nicht zufällig. Weglassungen, undefined, null sowie legitimerweise leere Werte sollten nicht einfach deshalb eine Bedeutung erhalten, nur weil irgendein Serialisierungs- oder Objektmergemechanismus zum Einsatz kommt.

Dies ist besonders in TypeScript von Bedeutung, wo eine optionale Eigenschaft und eine erforderliche Eigenschaft, deren Typ zufällig undefined enthält, zwei strukturell unterschiedliche Absichten ausdrücken – selbst wenn der umgebende Anwendungscode sie später auf dieselbe Weise behandelt. Striktere Compiler-Einstellungen können diese Unterscheidung auf Typebene durchsetzen, doch die tatsächlichen Daten, die zur Laufzeit durch Ihre Anwendung fließen, benötigen weiterhin ihre eigene Validierung.

Zwei Werte, die beim Lesen identisch erscheinen, können nach Überquerung einer Netzwerkgrenze oder durch eine Aktualisierungsoperation dennoch sehr unterschiedliche Kontrakte darstellen.

10. Eine Schließung behält eine Variable bei, nicht ein in der Zeit erstarrtes Abbild

Closures sind eines der mächtigsten Werkzeuge, die JavaScript bietet. Eine Funktion behält den Zugriff auf Variablen aus dem Scope, in dem sie definiert wurde – genau das ermöglicht es Callbacks, Factory-Funktionen, Event-Handlern und Modulmustern, so natürlich zu funktionieren, wie sie es tun.

Die gängige Abkürzung, die Menschen verwenden, ist, dass eine Closure „sich an einen Wert erinnert“. Eine präzisere Beschreibung lautet, dass sie den Zugriff auf die Bindung der Variable beibehält. Wenn sich der Wert dieser Variable vor dem Ausführen der Closure ändert, wird die Closure den neuen Wert sehen – nicht denjenigen, der zum Zeitpunkt ihrer Erstellung existierte.

Dies ist die klassische Erklärung für Schleifenfehler, die mit var zusammenhängen, doch erfahrene Entwickler stoßen oft auf subtilere Varianten desselben Problems in asynchronem Code und UI-Logik. Eine Callback-Funktion könnte ein Konfigurationsobjekt lesen, das nach Beginn der Operation geändert wurde. Ein Ereignishandler könnte auf einen nun veralteten Zustand zugreifen. Eine verzögerte Funktion könnte mit dem aktuellen Zustand eines veränderbaren Objekts arbeiten, obwohl der Entwickler annahm, sie würde das Objekt genauso verwenden wie zum Zeitpunkt der Planung der Funktion.

Frameworks fügen ihrerseits eigene Lebenszyklusregeln darauf hinzu, was dazu führt, dass der Effekt stärker auffällt. In React beispielsweise schließt ein während einer bestimmten Renderung erstellter Callback die Werte ab, die zu diesem Zeitpunkt vorhanden waren. Das kann ein Verhalten erzeugen, das wie veralteter Zustand aussieht, obwohl die zugrunde liegende JavaScript-Closure genau wie vorgesehen funktioniert. Die Überraschung entsteht daraus, dass man erwartet, der Callback könnte irgendwie automatisch auf den neuesten Zustand zugreifen – doch so funktionieren Closures nicht.

Die richtige Lösung hängt davon ab, was man tatsächlich benötigt. Manchmal möchte man den Wert in dem Zustand haben, in dem er zum Start der Operation existierte; in diesem Fall ist es sinnvoll, bereits zu Beginn einen stabilen Snapshot aufzunehmen. Manchmal möchte man den aktuellsten verfügbaren Wert, was eine Referenz im Stil einer Ref oder einen funktionalen Update erfordert. Und manchmal ist die richtige Lösung, dass sich ändernde Abhängigkeiten den Callback vollständig neu erstellen.

Die nützliche Frage ist, ob verzögerte Aufgaben den Zustand der Welt zum Zeitpunkt ihrer Planung widerspiegeln sollen oder den Zustand zur Zeit ihres tatsächlichen Ausführens. Die Klammer selbst hat dazu keine Meinung – sie bewahrt getreu jede Beziehung, die der Code eingerichtet hat, selbst wenn es nicht die Beziehung war, die man eigentlich schaffen wollte.

JavaScript verhält sich konsistent – unsere mentalen Abkürzungen nicht

Niemandes der hier beschriebenen Verhaltensweisen sind willkürliche Besonderheiten. const sperrt eine Bindung, nicht den darin enthaltenen Wert. Die Spread-Operation erstellt Kopien nur in einer Tiefe. Die standardmäßige Sortierung von Arrays wandelt die Elemente zunächst in Zeichenketten um. Promise-Handler werden als Microtasks ausgeführt. Die Destructuring-Operation reagiert standardmäßig nur auf undefined. Schließungen speichern lexikalische Bindungen statt fester Werte. Jede dieser Regeln wird von der Sprache mit vollkommener Konstanz angewendet.

Die Überraschungen entstehen, wenn Entwickler auf mentale Modelle vertrauen, die einfacher sind als das, was der Laufzeitumgebung tatsächlich vorgeschrieben ist. Wir sagen von einer Konstante, sie „kann sich nicht ändern“, von der Spread-Operation, sie „ermöglicht ein Kopieren“, von einer asynchronen Aufrufung, sie „befindet sich innerhalb von try/catch“, oder von einer Schließung, sie „erinnert sich an einen Wert“. Solche Vereinfachungen sind nützlich – bis plötzlich eine neue Anforderung genau auf die Details angewiesen ist, die sie weggelassen haben.

Was erfahrene Entwickler schützt, ist nicht das Auswendiglernen einer ständig wachsenden Liste von Fakten – sondern die Gewohnheit, Verträge dort explizit zu machen, wo Daten eine Grenze überschreiten. Das bedeutet, Werte aus der Außenwelt zu normalisieren, „absent“ und „invalid“ als separate Konzepte zu behandeln, zufällige Mutationen gemeinsamer Referenzen zu vermeiden, klarzustellen, wer für die Fehlerbehandlung einer asynchronen Operation verantwortlich ist, die ursprüngliche Bedeutung von Zeitstempeln und Zeitzonen beizubehalten sowie im Voraus zu entscheiden, ob ein verzögerter Callback auf dem alten oder aktuellen Zustand agieren soll.

Es geht beim Schreiben zuverlässiger JavaScript-Code nicht darum, alle flexiblen Funktionen der Sprache zu vermeiden. Es geht vielmehr darum, diese Funktionen zu nutzen, ohne anzunehmen, dass ihre kompakte Syntax mehr verspricht, als sie tatsächlich liefern kann.

JavaScript überrascht erfahrene Entwickler gerade deshalb weiterhin, weil Erfahrung Vertrauen in Code weckt, der vertraut erscheint. Das Risiko besteht darin, dass eine auf den ersten Blick vertraute Syntax trotzdem Entscheidungen bezüglich Referenzen, Typumwandlungen, Ausführungstermins und Fehlerverantwortlichkeiten verbirgt – Entscheidungen, die erst sichtbar werden, wenn sich etwas anderes im System verändert.

Die Sprache tut fast immer genau das, wofür sie instruiert wurde.

Die Überraschung entsteht, wenn man erkennt, wofür der eigene Code sie tatsächlich instruiert hat.

Verwandte Artikel

  • Zehn wiederkehrende JavaScript-Gewohnheiten, die heimlich Ihre Codebasis untergraben – Erklärt zehn häufige Fehler bei JavaScript und TypeScript, von lockerer Gleichheit bis hin zu Zustandsänderungen, und zeigt sichere Muster zur Ersetzung jedes davon.