Symbol
JavaScript‑Symbole (Symbol) sind eindeutige, unveränderliche Primitive, die sich als konfliktfreie Schlüssel und für interne Eigenschaften in Objekten eignen. Sie verhindern Namenskollisionen, bleiben bei normalen Aufzählungen verborgen und bilden die Grundlage für Meta‑Programmierung über well‑known symbols wie Symbol.iterator, Symbol.toStringTag oder Symbol.toPrimitive. Der Deep‑Dive behandelt Erzeugung, Vergleich und Sichtbarkeit von Symbolen, den globalen Registry‑Mechanismus (Symbol.for / Symbol.keyFor), typische Anwendungsfälle in Bibliotheken und APIs sowie Fallstricke und Best Practices für robuste, wartbare Implementierungen.
Inhaltsverzeichnis
Einführung
Symbol ist in JavaScript ein primitiver Datentyp, der eingeführt wurde, um eindeutige Kennungen zu erstellen. Jedes Symbol ist einzigartig, selbst wenn es mit derselben Beschreibung erzeugt wird. Es dient in erster Linie dazu, Kollisionen von Eigenschaftsnamen in Objekten zu vermeiden – also Fälle, in denen mehrere Teile eines Programms versehentlich denselben Schlüsselnamen verwenden.
Was ist ein Wert in JavaScript?
Zuerst sprechen wir und erinnern uns an ein paar allgemeine Konzepte und Gegebenheiten aus dem Bereich Computer Science!
Der Speicher
Bevor wir über Symbole sprechen können, müssen wir kurz ins Gedächtnis holen, wie ein Computer überhaupt Daten speichert. Das ist fundamental wichtig, weil ALLES was JavaScript macht - jede Variable, jedes Objekt, jedes Symbol - letztendlich nur Bytes im Speicher sind.
Man kann sich den RAM als eine gigantische Straße mit nummerierten Häusern vorstellen. Jedes “Haus” kann genau ein Byte (8 Bits) speichern. Diese “Hausnummern” nennt man Speicheradressen und sie sind in hexadezimaler Notation nummeriert.
In diesem Schema sieht man 8 aufeinanderfolgende Bytes im Speicher. Die Zahl 0x2A an Adresse 0x0001 entspricht der Dezimalzahl 42. Das ist wichtig zu verstehen. Alles im Computer ist nur eine Zahl - ein Buchstabe, ein Symbol, ein Objekt und alles, was man sonst kennt.
Die Adresse 0x0001 bedeutet: Gehe zum Byte Nr. 1 im Speicher. Das 0x am Anfang bedeutet nur “das ist eine hexadezimale Notation”. Hexadezimal ist praktisch für die Verwendung, weil ein Byte (8 Bits) genau durch 2 hexadezimale Ziffern dargestellt werden kann.
0 dezimal = 00 hexadezimal
255 dezimal = FF hexadezimal
Der Pointer
Jetzt wird es etwas interessanter. Ein Pointer (auf Deutsch “Zeiger”) ist einfach eine Speicheradresse. Statt einen Wert direkt zu speichern, speichert man die Adresse wo der tatsächliche Wert liegt. Hier kann wieder die Relation/Analogie zwischen der Adresse zu einem Haus und dem tatsächlichen Haus hilfreich sein.
Ein kleines JavaScript Beispiel.
let x = 42;Was passiert hier wirklich im Speicher?
Adresse 0x1000: [42]Variable x speichert 0x1000. Die Variable enthält also nicht die Zahl 42 direkt, sondern sie enthält die Information “der Wert liegt bei Adresse 0x1000”.
Wenn man also nun x verwenden würde, würde Folgendes passieren:
- JavaScript schaut in die Variable
xund findet dort eine Adresse0x1000 - JavaScript geht zu Adresse
0x1000 - JavaScript liest den Wert dort:
42
Diesen Prozess nennt man Dereferenzierung. Man folgt dem Pointer zu seinem Ziel.
Warum ist das wichtig für Symbole? Weil Symbole intern als Pointer funktionieren. Wenn man zwei Symbole vergleicht, vergleicht JavaScript nicht deren Inhalt, sondern deren Adressen. Das macht den Vergleich extrem schnell.
Tagged Values
Jetzt eine wichtige Frage: Woher weiß JavaScript, ob ein Wert im Speicher eine Zahl, ein String, ein Symbol oder ein Objekt ist?
Das Problem ist nämlich beim Speichern der Werte folgendes. Ein Wert bietet so gut wie nie eine eindeutige Interpretationsmöglichkeit, also auch eine Möglichkeit der Herleitung des Typs.
Wenn man nur 42 im Speichert sieht, könnte es sein:
- Die Zahl 42
- Der ASCII-Code für das Zeichen
'*' - Ein Teil einer Speicheradresse
- Etwas ganz anderes
JavaScript muss also irgendwie den Typ des Wertes mit speichern. Die Lösung heißt Tagged Pointers (getaggte oder gekennzeichnete Zeiger).
Die Idee ist interessant und einfach: JavaScript nutzt die “unteren Bits” eines 64-Bit-Werts als “Typ-Marker” (Tag). Die restlichen Bits enthalten den eigentlichen Wert oder einen Pointer.
64-Bit Wert im Speicher
0000 = Small Integer (SMI)
0001 = Pointer zu Object
0010 = Pointer zu String
0011 = Pointer zu Symbol
... etcDas bedeutet: Die letzten 4 Bits eines Werts sagen in JavaScript, was für ein Typ das ist. Die restlichen 60 Bits enthalten die eigentlichen Daten.
Veranschaulichen wir das an einem kleinen Beispiel.
let num = 42;
let sym = Symbol("test");So sehen die Werte wirklich im Speicher aus.
Variable: num
Bei der Zahl 42 ist es einfach. Die unteren 4 Bits sind 0000, das Tag für “Small Integer” und die oberen Bits enthalten die binäre Darstellung von 42 (101010).
Variable: sym
Bei einem Symbol ist das Tag 0011 (Symbol-Typ) und die oberen Bits enthalten einen Pointer - in diesem Fall 0x5000. Das ist die Adresse im Speicher, wo das eigentliche Symbol-Objekt liegt.
Symbol in RAM
Symbol-Objekt Struktur
Schauen wir uns an, wie man ein Symbol erstellt.
let sym = Symbol("mySymbol");JavaScript muss jetzt ein Symbol-Objekt im Speicher anlegen. Aber was ist ein Symbol-Objekt? Das ist eine Daatenstruktur - eine bestimmte Anordnung von Bytes, die zusammen ein Symbol repräsentieren.
Das passiert intern:
- JavaScript erstellt einen neuen, einzigartigen “Symbol-Wert”
- Dieser bekommt eine EINZIGARTIGE ID (bspw.
0x5000) - Die Beschreibung
mySymbolwird separat gespeichert
Variable sym zeigt auf ==> 0x5000
Adresse 0x5000: [Typ-Tag: Symbol][Eindeutige-ID: 12345][Beschreibung: "mySymbol"]Hier ist folgender Abschnitt der wichtigste: [Eindeutige-ID: 12345].
Im Speicher sieht das Symbol-Objekt ca. so aus.
+---------+----------------+--------+----------------------------+
| Offset | Feld | Wert | Beschreibung |
+---------+----------------+--------+----------------------------+
| +0 | Type Tag | 0x07 | Symbol-Typ |
| +4 | Hash | 0x8A3F | Für Hash-Tables |
| +8 | Symbol ID | 23871 | Eindeutige ID |
| +16 | Flags | 0x00 | z. B. global flag |
| +20 | Description* | 0x6000 | Pointer auf String |
+---------+----------------+--------+----------------------------+
|
v
Adresse 0x6000:
+------------------+
| "meinSymbol" |
+------------------+Gehen wir die einzelnen Felder drüber.
Offset +0 (Type Tag): Das ist ein interner Marker, der der Engine sagt “dies ist ein Symbol”. Jeder Object-Typ hat einen anderen Tag. Das ist zusätzlich zum Pointer-Tag und wird verwendet, wenn man dem Pointer gefolgt ist.
Offset +4 (Hash): Ein vorberechneter Hash-Wert für dieses Symbol. Wenn das Symbol als Property-Key verwendet wird, kann JavaScript diesen Hash sofort verwenden, ohne ihn neu berechnen zu müssen. Das ist eine Performance-Optimierung.
Offset +8 (Symbol ID): Das ist das Herzstück. Diese Zahl ist das, was das Symbol wirklich einzigartig macht! Sie wird nie zweimal vergeben. In diesem Beispiel ist es die 23871.
Offset +16 (Flags): Zusätzliche Informationen. Zum Beispiel: Ist dies ein globales Symbol (von Symbol.for())? Ist es ein Well-Known Symbol (wie Symbol.iterator)? Diese Informationen werden als einzelne Bits gespeichert.
Offset +20 (Description): Ein Pointer zur Beschreibung. Wichtig: Die Beschreibung ist optional und hat keinen Einfluss auf die Identität des Symbols.
Es ist also die Symbol ID, die das Symbol ausmacht. Nicht die Beschreibung! Zwei Symbole mit der gleichen Beschreibung haben verschiedene IDs und verschieden.
Wie wird diese ID generiert? Werfen wir einen Blick auf eine vereinfachte Version.
class Symbol {
static uint64_t next_id = 0; // Globaler Counter
uint64_t id;
String* description;
Symbol(String* desc) {
this->id = ++next_id; // Jedes Symbol bekommt eine neue ID
this->description = desc;
}
}Somit ist next_id eine statische Variable, was bedeutet, dass zur Klasse gehört, nicht zu einer einzelnen Instanz. Es gibt nur EINE next_id für ALLE Symbole. Jedes Mal, wenn ein neues Symbol erstellt wird, wird diese Zahl erhöht.
Das Schlüsselwort static bedeutet: Dieser Wert wird geteil von allem Symbol-Instanzen. Selbst wenn man Millionen Symbole erstellt, gibt es nur EINEN Counter.
Zwei Symbole vergleichen
Jetzt verstehen wir, wie Symbole gespeichert werden. Aber was passiert, wenn man sie vergleicht?
let symOne = Symbol("test");
let symTwo = Symbol("test");
console.log(symOne === symTwo);falseDieser Code gibt false zurück. Warum? Beide haben die gleiche Beschreibung. Wir wissen aber, dass nicht die Beschreibung ein Symbol ausmacht! Schauen wir uns also an, was im Computer passiert.
Was macht die CPU?
Der Prozessor führt die eigentlichen Berechnungen aus. JavaScript Code wird letztendlich zu CPU-Befehlen übersetzt. Hier ist eine vereinfachte Version in Assembly-Sprache (Pseudo-Code).
; Pseudo-Code
LOAD R1, [symOne] ; Lade symOne in Register R1
; R1 = 0x5000 | 0x3 (Pointer + Tag)
LOAD R2, [symTwo] ; Lade symTwo in Register R2
; R2 = 0x5100 | 0x3 (anderer Pointer + Tag)
CMP R1, R2 ; Vergleiche die Werte
; 0x50003 === 0x51003 ?
; NEIN
JNE not_equal ; Springe zu not_equalDer Vergleich ist ein einziger Maschinenbefehl. JavaScript vergleicht einfach die beiden Pointer-Werte. Weil symOne auf Adresse 0x5000 zeigt und symTwo auf 0x5100, sind sie unterschiedlich.
Symbole als Property-Keys
Warum Symbole als Objekt-Schlüssel?
Bevor wir weiter in die technischen Details eintauchen, müssen wir verstehen, warum Symbole überhaupt als Property-Keys in Objekten verwendet werden können und sollten.
Die Geschichte beginnt mit einem fundamentalen Problem in JavaScript: Namespace-Kollisionen.
In JavaScript sind Objekte die zentrale Datenstruktur. Alles dreht sich um Objekte. Properties werden String-Keys angesprochen. Das war viele Jahre der einzige Weg, Properties zu benennen. Aber genau hier liegt das Problem. Was passiert, wenn zwei verschiedene Teile eines Programms - vielleicht zwei verschiedene Bibliotheken - dieselben Property-Namen verwenden wollen?
Das konkrete Problem
Stellen wir uns vor, wir verwenden eine Bibliothek für Datenvalidierung und eine andere für Datenserialisierung. Beide Bibliotheken wollen an Objekten eine metadata Property hinzufügen.
// Validierungs-Bibliothek
function validateUser(user) {
user.metadata = {
validated: true,
timestamp: Date.now()
};
}
// Serialisierungs-Bibliothek
function serializeUser(user) {
user.metadata = {
format: "json",
version: 2
};
}
const user = { name: "John" };
validateUser(user);
serializeUser(user);
// An diesem Punkt ist "metadata" überschrieben
console.log(user.metadata);{ format: 'json', version: 2 }Die Validierungs-Informationen sind verloren. Die zweite Bibliothek hat die erste überschrieben, weil beide denselben Strin-Key metadata verwendet haben. Das ist eine Namespace-Kollision.
Natürlich könnte man versuchen, eindeutige Präfixe zu verwenden.
user.__validation_lib_metadata = { ... };
user.__serialization_lib_metadata = { ... };Aber das ist umständlich, fehleranfällig und letztendlich kann man nie sicher sein, dass nicht doch jemand denselben Präfix verwendet. Außerdem verschmutzt es den öffentlichen Namespace des Objekts.
Die Lösung mit Symbolen
Symbole lösen dieses Problem fundamental, weil jedes Symbol garantiert einzigartig ist.
// Validierungs-Bibliothek
const VALIDATION_META = Symbol("validation.metadata");
function validateUser(user) {
user[VALIDATION_META] = {
validated: true,
timestamp: Date.now()
};
}
// Serialisierungs-Bibliothek
const SERIALIZATION_META = Symbol("serialization.metadata");
function serializeUser(user) {
user[SERIALIZATION_META] = {
format: "json",
version: 2
};
}
const user = { name: "John" };
validateUser(user);
serializeUser(user);
// Beide Metadaten existieren nebeneinander
console.log(user[VALIDATION_META]);
console.log(user[SERIALIZATION_META]);{ validated: true, timestamp: 1762149164569 }
{ format: 'json', version: 2 }Beide Bibliotheken können ihre eigenen Metadaten speichern, ohne sich gegenseitig zu stören. Obwohl beide Symbole die gleiche Beschreibung haben könnten, sind sie intern völlig unterschiedlich. Es ist unmöglich, dass sie kollidieren.
Wie werden Symbol-Keys gespeichert?
Jetzt wird es noch einmal interessant. Wenn wir ein Symbol als Property-Key verwenden, passiert intern etwas Besonderes. JavaScript behandelt Symbol-Properties anders als String-Properties. Das ist keine willkürliche Design-Entscheidung, sondern hat tiefgreifende Gründe.
Schauen wir uns ein einfaches Beispiel an.
const userId = Symbol("id");
const userName = "name";
const user = {
[userId]: 123,
[userName]: "John"
};Was passiert hier wirklich? JavaScript muss irgendwie beide Properties speichern, aber es behandelt sie intern komplett unterschiedlich.
Die Objekt-Struktur im Detail
Jedes JavaScript-Objekt ist im Speicher nicht einfach ein flacher Haufen von Key-Value-Paaren. Es ist eine komplexe Datenstruktur mit verschiedenen Bereichen. Man kann sich ein Objekt wie ein Haus mit mehreren Stockwerken vorstellen, wobei jedes Stockwerk für einen bestimmten Zweck gebaut wurde.
Stellen wir uns vor, unser user Objekt liegt an der Speicheradresse 0x7000. An dieser Adresse beginnt eine Struktur, die verschiedene Pointer (Verweise) auf andere Speicherbereiche enthält.
Objekt bei Adresse 0x7000:
+---------------------------------+
| Shape/Map Pointer → 0x7100 | <- Beschreibt die "Form" des Objekts
| Properties Pointer → 0x7200 | <- String-Properties werden hier gespeichert
| Elements Pointer → 0x7300 | <- Array-ähnliche numerische Indices
| Symbol Table Pointer → 0x7400 | <- Symbol-Properties werden HIER gespeichert
+---------------------------------+Der entscheidende Punkt: Es gibt einen separaten Bereich nur für Symbol-Properties. Die Symbol-Properties werden nicht zusammen mit den String-Properties gespeichert, sondern in ihrer eigenen Tabelle.
Schauen wir uns an, wie das konkret aussehen könnte.
String-Properties-Tabelle bei 0x7200
+--------------+---------------+
| String Key | Value |
+--------------+---------------+
| "name" | Pointer zu |
| | String "John" |
+--------------+---------------+Symbol-Properties-Tabelle bei 0x7400
+------------------+--------------+
| Symbol Pointer | Value |
+------------------+--------------+
| 0x5000 (userId) | Number 12345 |
+------------------+--------------+Wichtig: In der Symbol-Tabelle wird nicht die Beschreibung gespeichert, als nicht id, sondern der Pointer zum Symbol-Objekt selbst (0x5000). Das Symbol-Objekt hat eine eindeutige ID.
Warum diese Trennung?
Diese Architektur-Entscheidung hat mehrere wichtige Gründe:
Grund 1: Performance bei normalen Operationen
Die meisten Operationen in JavaScript arbeiten mit String-Properties. Wenn man user.name schreibt oder Object.keys(user) aufruft, interessieren einen normalerweise nur die String-Properties. Durch die Trennung muss die JavaScript-Engine nicht durch Symbol-Properties iterieren, wenn sie String-Properties sucht. Das macht normale Operationen schneller.
Stellen wir uns vor, wir hätten ein Objekt mit 100 String-Properties und 50 Symbol-Properties. Jetzt machen wir eine Schleife über alle Keys.
for (let key in user) {
console.log(key)
}Ohne die Trennung müsste JavaScript durch alle 150 Properties gehen und bei jeder einzelnen prüfen: “Ist das ein String oder ein Symbol”. Mit der Trennung kann JavaScript einfach sagen: “Gehe nur durch die String-Properties”. Die 50 Symbol-Properties werden einfach ignoriert, weil sie in einer anderen Tabelle liegen.
Grund 2: Sichtbarkeit und Privatsphäre
Symbol-Properties sollen bei normalen Enumerationen “unsichtbar” sein. Das ist ein Design-Feature, kein Bug. Wenn jemand Object.keys(user) aufruft, soll er nur die öffentlichen, für ihn gedachten Properties sehen - nicht die internen Metadaten, die Bibliotheken dort gespeichert haben.
Durch die separate Speicherung ist diese Unsichtbarkeit automatisch gegeben. Die Engine muss nicht bei jeder Property prüfen und entscheiden, ob sie sichtbar sein soll oder nicht. Sie schaut einfach nur in die String-Property-Tabelle.
Hier eine kleine Veranschaulichung.
// Symbol als Property-Name definieren
const userSalary = Symbol("salary");
// User Objekt erstellen und normale Properties zuweisen
const user = {};
user.name = "John";
user.age = 40;
// Wert über Symbol zuweisen
user[userSalary] = 40000;
// Sichtbaren Eigenschaften abrufen
console.log(Object.keys(user));
// Salary abrufen
console.log(user[userSalary]);
// Symbole anzeigen
console.log(Object.getOwnPropertySymbols(user));[ 'name', 'age' ]
40000
[ Symbol(salary) ]Wie man hier sehen kann, sind die Symbol-Properties standardmäßig nicht sichtbar, also nicht “enumerable”.
Grund 3: Optimierungsmöglichkeiten
JavaScript-Engines können String-Properties und Symbol-Properties unterschiedlich optimieren. String-Properties werden häufig in “Inline-Caches” gespeichert, weil sie oft verwendet werden. Symbol-Properties brauchen diese Optimierung meist nicht, weil sie seltener angesprochen werden. Durch die Trennung kann die Engine für jeden Typ die optimale Strategie wählen.
Property-Lookup verstehen
Wenn JavaScript eine Property lesen will, muss es diese zunächst finden. Dieser Prozess heißt Property Lookup (Eigenschaftssuche). Je nachdem, ob es sich um einen String oder ein Symbol handelt, läuft dieser Prozess unterschiedlich ab.
String-Property Lookup
console.log(user.name);Was passiert hier Schritt für Schritt?
- JavaScript erkennt:
nameist ein String-Key (weil es Dot-Notation ist) - JavaScript geht zum Objekt an Adresse
0x7000 - JavaScript liest den Properties-Pointer (zeigt auf
0x7200) - JavaScript geht zu Adresse
0x7200(String-Properties-Tabelle) - JavaScript durchsucht die Tabelle nach dem String
"name" - JavaScript findet den Eintrag und liest den Value
- JavaScript gibt den Value zurück:
"John"
Symbol-Property-Lookup
console.log(user[userId]);Was passiert hier?
- JavaScript schaut sich die Variable
userIdan - JavaScript erkennt den Tag-Bits: Das ist ein Symbol (Tag 0011)
- JavaScript extrahiert den Pointer aus
userId:0x5000 - JavaScript geht zum Objekt an Adresse
0x7000 - JavaScript liest den Symbol-Tabelle-Pointer (zeigt auf
0x7400) - JavaScript geht zu Adresse
0x7400(Symbol-Properties-Tabelle) - JavaScript durchsucht die Tabelle nach dem Symbol-Pointer
0x5000 - JavaScript findet den Eintrag und liest den Value
- JavaScript gibt den Value zurück: 12345
Der entscheidende Unterschied: Bei Strings muss die Tabelle nach einem String-Match durchsucht werden (Zeichen für Zeichen vergleichen). Bei Symbolen wird nach einem Pointer-Match gesucht (einfacher Integer-Vergleich).
Warum ist Symbol-Loopup schneller?
Beim String-Vergleich muss JavaScript potenziell jeden Buchstaben vergleichen. Beim Symbol-Vergleich ist es ein einziger numerischer Vergleich.
Sichtbarkeit von Symbolen
Das Konzept der Sichtbarkeitsebenen
Eines der wichtigsten Features von Symbolen ist ihre gesteuerte Sichtbarkeit. Sie sind nicht einfach “unsichtbar” oder “versteckt” - es gibt verschiedene Ebenen der Sichtbarkeit und verschiedene APIs können auf verschiedenen Ebenen zugreifen.
Das ist ein durchdachtes Design-Feature. Symbole sollten bei normalen, alltäglichen Operationen unsichtbar bleiben, um keine Verwirrung zu stiften und keine Namenskonflikte zu verursachen. Aber sie sollten trotzdem zugänglich sein, wenn man explizit danach sucht, zum Beispiel für Debugging oder Reflection.
Schauen wir uns ein Beispiel an.
const publicKey = "name";
const hiddenKey = Symbol("secret");
const user = {
[publicKey]: "John",
[hiddenKey]: "topSecretValue"
};Dieses einfache Objekt hat zwei Properties. Aber je nachdem, wie wir das Objekt betrachten, sehen wir unterschiedlich viele Properties.
Ebene 1: Normale Enumeration - Symbole sind unsichtbar
Die erste und wichtigste Ebene ist die normale Enumeration. Das sind alle Standard-Methoden, die die meisten Entwickler täglich verwenden, um über Objekte zu iterieren oder ihre Keys abzurufen.
for (let key in user) {
console.log(key, "=", user[key]);
}name = JohnWie man hier sehen kann, wird Symbol komplett übersprungen.
Die for...in Schleife sieht nur String-Properties. Warum? Weil sie intern nur die String-Properties-Tabelle durchläuft. Die Symbol-Tabelle wird nicht einmal angeschaut. Das ist bewusst so designed.
Warum ist das wichtig?
Stellen wir uns vor, wir verwenden eine externe Bibliothek, die interne Metadaten an Objekten speichert.
// Die Bibliothek macht intern
user[Symbol("lib.internal.cache")] = { ... };
user[Symbol("lib.internal.state")] = { ... };
// Unser Code
for (let key in user) {
console.log(key);
}Ohne diese Unsichtbarkeit würden wir plötzlich Properties sehen, die wir nie erstellt haben. Das würde verwirrend sein und könnte unseren Code brechen, wenn man annimmt, dass alle Properties im Loop im Loop von uns stammen.
Object.keys()
Object.keys() gibt ein Array mit allen enumerierbaren String-Keys zurück. Symbol-Keys sind nicht dabei. Das ist konsistent mit der for...in Schleife.
const keys = Object.keys(user);
console.log(keys);[ 'name' ]Was bedeutet “enumerierbar”?
In JavaScript hat jede Property ein internes Flag namens enumerable. Nur Properties, bei denen diesen Flag true ist, erscheinen in for...in und Object.keys(). Aber Symbol-Properties haben ein zusätzliches “Unsichtbarkeits-Feature”: Selbst wenn das enumerable Flag true wäre, würden sie trotzdem nicht erscheinen, weil sie in einer separaten Tabelle liegen.
Object.values() und Object.entries()
Diese Methoden verhalten sich genauso.
console.log(Object.values(user));
console.log(Object.entries(user));[ 'John' ]
[ [ 'name', 'John' ] ]Sie (Methoden) arbeiten auf der String-Properties-Tabelle und ignorieren die Symbol-Tabelle.
JSON.stringify()
Besonders interessant ist das Verhalten bei der Serialisierung.
const json = JSON.stringify(user);
console.log(json);{"name":"John"}Symbol-Properties werden bei JSON-Serialisierung vollständig ignoriert. Das hat einen praktischen Grund: JSON als Format kennt keine Symbole. JSON unterstützt nur Strings als Keys. Daher macht es keinen Sinn, Symbole in JSON aufzunehmen - sie können nicht wieder deserialisiert werden.
Hier eine praktische Implikation.
Symbole können gut für Metadaten verwendet werden, die nicht serialisiert werden sollen.
const metadata = Symbol("metadata");
const article = {
title: "JavaScript Symbol",
content: "...",
[metadata]: {
lastModified: Date.now(),
author: "system",
internalId: 12345
}
};
// Beim Speichern in eine Datenbank oder Senden über eine API
const json = JSON.stringify(article);
console.log(json);{"title":"JavaScript Symbol","content":"..."}Das ist oft genau das gewünschte Verhalten. Interne Metadaten sollen nicht nach außen gehen.
Ebene 2: Gezielte Symbol-Suche
Machnmal will man aber doch an die Symbol-Properties ran. Vielleicht für Debugging, oder weil man eine Library schreibt, die mit Symbol-Properties arbeitet. Dafür gibt es spezielle APIs.
Object.getOwnPropertySymbols()
Diese Methode gibt ein Array mit allen Symbol-Properties eines Objekts zurück.
const publicKey = "name";
const hiddenKey = Symbol("secret");
const user = {
[publicKey]: "John",
[hiddenKey]: "topSecretValue"
};
const symbols = Object.getOwnPropertySymbols(user);
console.log(symbols);
console.log(symbols[0] === hiddenKey);
console.log(user[symbols[0]]);[ Symbol(secret) ]
true
topSecretValueWas passiert intern?
Diese Methode macht etwas, was normale Enumeration nicht macht: Sie schaut in die Symbol-Tabelle. Sie iteriert durch alle Einträge in der Symbol-Tabelle und gibt die Symbol-Keys zurück.
Die Methode gibt nur die Keys zurück, nicht die Values (Werte). Um an die Values zu kommen, muss man dann obj[symbol] verwenden.
Wichtige Einschränkung
getOwnPropertySymbols() gibt nur die Symbol-Properties des Objekts selbst zurück, nicht die von Prototypen.
const parent = {
[Symbol("parentSymbol")]: "Parent value"
};
const child = Object.create(parent);
child[Symbol("childSymbol")] = "Child value";
console.log(Object.getOwnPropertySymbols(child));[ Symbol(childSymbol) ]Das “Own” in getOwnPropertySymbols bedeutet: Nur eigene Properties, nicht geerbte.
Ebene 3: Vollständige Reflection
Die mächtigste Ebene ist die vollstädnige Reflection. Hier bekommt man absolut alles: String-Properties und Symbol-Properties, alle zusammen.
Reflect.ownKeys()
Diese Methode gibt ein Array zurück, das sowohl String-Keys als auch Symbol-Keys enthält. Sie kombiniert sozusagen Object.keys() und Object.getOwnPropertySymbols().
const publicKey = "name";
const hiddenKey = Symbol("secret");
const user = {
[publicKey]: "John",
[hiddenKey]: "topSecretValue"
};
const allKeys = Reflect.ownKeys(user);
console.log(allKeys);[ 'name', Symbol(secret) ]Warum gibt es diese Methode?
Reflect.ownKeys() ist Teil der Reflection-API, die in ES6 eingeführt wurde. Diese API ist designed für Meta-Programmierung - also Code, der anderen Code untersucht oder manipuliert. Wenn man wirklich alles über ein Objekt wissen will, braucht man Reflect.ownKeys().
Hier ein weiteres praktisches Beispiel - Deep Clone.
function deepClone(obj) {
const clone = {};
// Hole ALLE Keys - Strings und Symbols
const allKeys = Reflect.ownKeys(obj);
for (let key of allKeys) {
const value = obj[key];
// Klone den Wert (vereinfacht)
if (typeof value === "object" && value !== null) {
clone[key] = deepClone(value);
} else {
clone[key] = value;
}
}
return clone;
}
const original = {
name: "Test",
[Symbol("id")]: 123
};
const cloned = deepClone(original);
console.log(cloned.name);
console.log(Object.getOwnPropertySymbols(cloned));Test
[ Symbol(id) ]Ohne Reflect.ownKeys() würde unser Klon nur die String-Properties kopieren und die Symbol-Properties auslassen.
Die Philosophie hinter den Ebenen
Diese drei Ebenen sind kein Zufall. Sie repräsentieren unterschiedliche “Intentionen”.
Ebene 1 (normal): “Ich will mit den öffentlichen, dokumentierten Properties arbeiten”.
- Das ist der Standard-Fall
- Hier sollen Symbol-Properties nicht stören
**Ebene 2 (Symbole): “Ich will speziell mit Symbol-Properties arbeiten”
- Für Libraries, die mit Symbolen als Mechanismus arbeiten
- Für Debugging von Symbol-bezogenem Code
**Ebene 3 (Reflection): “Ich will wirklich ALLES über dieses Objekt wissen”
- Für Meta-Programmierung
- Für Serialisierungs-Libraries
- Für Developer-Tools und Debugger
Symbol.for() und Symbol.keyFor() - Die globale Registry
Das Problem wiederverwendbarer Symbole
Wir erinnern uns, dass Symbole einzigartig sind. Das ist normalerweise genau das, was wir wollen. Aber es gibt Situationen, wo diese Einzigartigkeit zum Problem wird.
Stellen wir uns folgendes Szenario vor. Wir entwickeln eine große Anwendung mit mehreren Modulen. Mehrere Module müssen auf dieselbe “versteckte” Property zugreifen. Wie machen sie das?
Der native Ansatz
// module-a.js
export const USER_ID = Symbol("user.id");
// module-b.js
import { USER_ID } from "./module-a.js";
// module-c.js
import { USER_ID } from "./module-c.js";Das funktioniert, aber es schafft eine Abhängigkeit. Jedes Modul, das dieses Symbol braucht, muss es von module-a importieren. Das kann problematisch werden:
- Was, wenn die Module in unterschiedlichen Bundles liegen?
- Was, wenn das Symbol von einem externen Package kommt, das mehrmals installiert ist (NPM und Abhängigkeits-Wahnsinn)?
- Was, wenn Module dynamisch geladen werden und nicht zur Compile-Zeit bekannt sind?
In all diesen Fällen könnte man versehentlich mehrere Instanzen desselben “konzeptionellen” Symbols haben.
// Bundle 1 hat eine Kopie von user-id-symbol
const userId1 = Symbol("user.id");
// Bundle 2 hat eine andere Kopie
const userId2 = Symbol("user.id");
// userId1 !== userId2, obwohl sie dasselbe sein solltenDie Lösung: Globale Symbol-Registry
JavaScript bitet eine elegante Lösung: eine globale Registry für Symbole. Das ist eine Art “Telefonbuch”, das String-Keys auf Symbole mappt. Der JavaScript-Code einer Anwendung - egal in welchem Modul oder Bundle - teilt sich diese Registry.
// Irgendwo im Bundle 1
const userId1 = Symbol.for("app.user.id");
// Irgendwo im Bundle 2
const userId2 = Symbol.for("app.user.id");
// Jetzt sind sie identisch
console.log(userId1 === userId2);trueSchauen wir uns ein Beispiel an, welches dieses Verhalten verdeutlichen würde. Dazu brauchen wir 3 Dateien, damit wir wirklich mit Modulen/Bundles die Lage nachstellen.
Zuerst erstellen wir die erste Datei (bundleOne.js).
// --- bundleOne.js ---
export const userId1 = Symbol.for("app.user.id");
export const userToken1 = Symbol("app.user.token");Nun erstellen wir die zweite Datei (bundleTwo.js).
// --- bundleTwo.js ---
export const userId2 = Symbol.for("app.user.id");
export const userToken2 = Symbol("app.user.token");Und nun verwenden wir diese beiden Dateien und versuchen die Symbole zu verlgiehcne. Das machen wir uns unserer main.js.
// --- main.js ---
import { userId1, userToken1 } from "./bundleOne.js";
import { userId2, userToken2 } from "./bundleTwo.js";
console.log(userId1 === userId2);
console.log(Symbol.keyFor(userId1));
console.log("--- --- ---");
console.log(userToken1 === userToken2);
console.log(Symbol.keyFor(userToken1));true
app.user.id
--- --- ---
false
undefinedWas passiert hier intern
-
Symbol.for("app.user.id")sucht in der globalen Symbol-Registry, ob ein Symbol mit diesem Key existiert.- Falls nicht vorhanden, wird es erstellt und eingetragen.
- Falls vorhanden, wird dasselbe Symbol-Objekt zurückgegeben.
-
Symbol("app.user.token")erstellt immer ein neues, eindeutiges Symbol, das nicht global registriert ist.
Das bedeutet: Der erste Aufruf von Symbol.for("app.user.id") erstellt das Symbol. Alle weiteren Aufrufe mit demselben String geben dasselbe Symbol zurück.
Die Registry im Detail verstehen
Die globale Registry ist konzeptionell eine Hash-Tabelle, die String-Keys auf Symbol-Objekte mappt. Man kann sie sich so vorstellen:
+----------------------+---------------------+
| String Key | Symbol Pointer |
+----------------------+---------------------+
| "app.user.id" | -> Symbol @ 0x5000 |
| "react.element" | -> Symbol @ 0x5100 |
| "lib.config.timeout" | -> Symbol @ 0x5200 |
+----------------------+---------------------Wenn Symbol.for("app.user.id") aufgerufen wird:
- JavaScript hasht den String
app.user.idzu einem Index in der Hash-Tabelle. - JavaScript schaut an diesem Index nach.
- Falls ein Eintrag existiert, gib den Symbol-Pointer zurück.
- Falls kein Eintrag existiert:
- Erstelle ein neues Symbol-Objekt
- Speichere die Zuordnung (
app.user.id=>Symbolin der Registry) - Gib das neue Symbol zurück
Wichtig: Diese Registry ist global im JavaScript-Laufzeit-Kontext. Dieser Fakt bedeutet:
- In Browsern: Global über alle Scripte und iFrames hinweg (mit derselben Origin)
- In NodeJS: Global im gesamten Prozess
- In Web Workers: Jeder Worker hat seine eigene Registry
Symbol.keyFor() - Die Rückrichtung
Wenn man ein Symbol hat und wissen möchte, ob es in der globalen Registry ist und unter welchem Key, verwendet man Symbol.keyFor().
Hier ein kleines Beispiel.
const globalSymbol = Symbol.for("myKey");
const localSymbol = Symbol("myKey");
console.log(Symbol.keyFor(globalSymbol));
console.log(Symbol.keyFor(localSymbol));undefinedSymbol.keyFor() macht folgendes.
- Prüft, ob das Symbol ein “global registry symbol” ist (das wird intern geflagt)
- Falls nein: Gib
undefinedzurück - Fall ja: Suche das Symbol in der Registry und gib den String-Key zurück
- Falls nein: Gib
Das Symbol-Objekt hat intern ein Flag, das sagt: “Ich bin ein globales Symbol”. Wenn dieses Flag gesetzt ist, durchsucht JavaScript die Registry nach diesem Symbol und gibt den zugehörigen String zurück.
Lokale vs. globale Symbole
Es ist wichtig, den Unterschied zwischen lokalen und globalen Symbolen zu verstehen.
const localSymbol = Symbol("description");const localSymbol = Symbol.for("description");Lokales Symbol
- Wird mit
Symbol()erstellt - Ist nur über die Variable zugänglich, in der es gespeichert ist
- Nicht in der globalen Registry
Symbol.keyFor(localSymbol)gibtundefinedzurück
Globales Symbol
- Wird mit
Symbol.for()erstellt - Ist überall über
Symbol.for("key")zugänglich - Ist in der globalen Registry gespeichert
Symbol.keyFor(globalSymbol)gibt den Registry-Key zurück
Im Speicher unterscheiden sich diese durch ein internes Flag.
Lokales Symbol-Objekt @ 0x5000:
+---------------------------------+
| Type Tag: Symbol |
| Hash: 0x8A3F |
| Symbol ID: 23871 |
| Flags: 0x00 <--------- | -- Global-Bit = 0
| Description: "description" |
+---------------------------------+
Globales Symbol-Objekt @ 0x5100:
+---------------------------------+
| Type Tag: Symbol |
| Hash: 0x7B2E |
| Symbol ID: 23872 |
| Flags: 0x01 <--------- | -- Global-Bit = 1
| Description: "description" |
+---------------------------------+Das Global-Bit im Flags-Feld markiert ein Symbol als “Teil der globalen Registry”.
Best Practices für Symbol.for()
1. Verwende namespaced Keys
Auch, wenn Symbole Kollisionen vermeiden sollen, gilt das nur für Symbol(). Bei Symbol.for() gibt es ein globales Namespace-Problem: Wenn zwei Libraries zufällig denselben String verwenden, bekommen sie dasselbe Symbol.
// SCHLECHT - zu generisch - Kollisionsgefahr
const id = Symbol.for("id");
// GUT - eindeutiger Namespace
const id = Symbol.for("myapp.models.user.id");2. Verwende Symbol.for() nur wenn nötig
Symbol.for() sollte nur verwendet werden, wenn man wirklich ein geteiltes Symbol braucht. Für interne Zwecke ist ein normales, lokales Symbol besser.
// Für interne, lokale Zwecke
const internal = Symbol("internal");
// Für Cross-Modul-Kommunikation
const shared = Symbol.for("mylib.public.id");3. Dokumentiere globale Symbole
Weil globale Symbole überall zugänglich sind, sollten sie gut dokumentiert werden.
/**
* Globales Symbol für User-IDs in der gesamten Anwendung.
* Wird von Auth, User-Service und DB-Layer verwendet.
* Registry-Key: "app.user.id"
*/
export const USER_ID = Symbol.for("app.user.id");Praktisches Beispiel zur Veranschaulichung
In diesem Beispiel versuche ich zu zeigen, wann ein Symbol.for() nützlich sein kann.
Für dieses Beispiel benötigen wir in einem Ordner folgende drei Dateien.
- pluginApi.js
- pluginImplementation.js
- main.js
const PLUGIN_INTERFACE = Symbol.for("app.plugin.interface");
export function registerPlugin(plugin) {
if (!plugin[PLUGIN_INTERFACE]) {
throw new Error("Das Plugin implementiert nicht die Schnittstelle");
}
const iface = plugin[PLUGIN_INTERFACE];
console.log("✅ Plugin erkannt:", iface.version);
iface.init();
console.log("📦 Plugin registriert!");
} const PLUGIN_INTERFACE = Symbol.for("app.plugin.interface");
export const myPlugin = {
name: "PluginOne",
version: "1.0.2",
[PLUGIN_INTERFACE]: {
version: "1.0.2",
init() {
console.log("🛠️ Plugin initialisiert");
},
destroy() {
console.log("🗑️ Plugin beendet");
}
}
};Und nun werden wir diese beiden Dateien in unserer main.js verwenden.
import { registerPlugin } from "./plugin_api.js";
import { myPlugin } from "./plugin_implementation.js";
const sym1 = Symbol.for("app.plugin.interface");
const sym2 = Symbol.for("app.plugin.interface");
console.log("Symbole gleich?", sym1 === sym2);
registerPlugin(myPlugin);
console.log("Hat Symbol-Interface:", myPlugin.hasOwnProperty(sym1));
console.log("Interface-Version:", myPlugin[sym1].version);
myPlugin[sym1].destroy();Symbole gleich? true
✅ Plugin erkannt: 1.0.2
🛠️ Plugin initialisiert
📦 Plugin registriert!
Hat Symbol-Interface: true
Interface-Version: 1.0.2
🗑️ Plugin beendetBeide Dateien können das Symbol verwenden, ohne voneinander abhängig zu sein. Sie sind nur über den String app.plugin.interface verbunden.
Well-Known Symbols - Meta-Programmierung
Was sind Well-Known Symbols?
Well-Known Symbols sind eine der mächtigsten und gleichzeitig am wenigsten verstandenen Features von JavaScript. Sie sind vordefinierte Symbole, die vom JavaScript-Standard selbst bereitgestellt werden. Diese Symbole ermöglichen es uns, das fundametale Verhalten von Objekten anzupassen - Ding, die normalerweise fest in der Sprache eingebaut sind.
Man könnte sagen: Well-Known Symbols sind “Hooks” in die JavaScript-Engine. Sie sind Schnittstellen, über die man der Engine sagen kann: “Hey, wenn du mit Objekten arbeitest, mach es anders als normal”.
Diese Symbole sind als statische Properties auf dem Symbol-Konstruktor verfügbar.
console.log(Symbol.iterator);
console.log(Symbol.toStringTag);
console.log(Symbol.toPrimitive);
// ... und viele mehrSymbol(Symbol.iterator)
Symbol(Symbol.toStringTag)
Symbol(Symbol.toPrimitive)Warum sind sie Symbole?
Die JavaScript-Spec Designer hätten diese Features auch als normale String-Properties implementieren können, etwa obj.__iterator__ oder obj.__toStringTag__. Das hätte allerdings zwei Probleme:
- Kollisionsgefahr: Jemand könnte versehentlich eine Property mit demselben Namen erstellen.
- Verschmutzung: Diese “magischen” Properties würden in
Object.keys()auftauchen und normale Enumeration stören.
Durch die Verwendung von Symbolen sind diese “Hooks” unsichtbar bei normaler Enumeration, aber trotzdem zugänglich wenn man sie braucht.
Symbol.iterator - Der Herzschlag der Iteration
Das wichtigste und am häufigsten verwendete Well-Known Symbol ist Symbol.iterator. Es definiert, wie ein Objekt iteriert werden kann - also wie die for...of-Schleife, der Spread-Operator ... und andere Iterator-Konsumenten mit dem Objekt umgehen.
Das Problem, das Symbol.iterator löst
JavaScript hat viele verschiedene Arten von iterierbaren Dingen: Arrays, Strings, Maps, Sets, NodeLists und mehr. Aber wie soll JavaScript wissen, wie es über ein beliebiges Objekt iterieren soll?
Über das nachstehende Objekt können wir nicht einfach so drüber iterieren.
const myData = {
users: ["John", "Tom", "Anna"],
posts: ["Post 1", "Post 2"]
};
for (let item of myData) { console.log(item); }TypeError: myData is not iterableDas Problem: JavaScript weiß nicht, wie es über dieses benutzerdefinierte Objekt iterieren soll. Soll es über die Keys iterieren? Über die Values? Über beide? In welcher Reihenfolge?
Mit Symbol.iterator kann man das selbst festlegen.
const myData = {
users: ["John", "Tom", "Anna"],
posts: ["Post 1", "Post 2"],
[Symbol.iterator]() {
// Definiert, wie iteriert werden soll.
const allItems = [...this.users, ...this.posts];
let index = 0;
return {
next() {
if (index < allItems.length) {
return {
value: allItems[index++],
done: false
};
} else {
return {
done: true
};
}
}
};
}
};
// Jetzt funktioniert es
for (let item of myData) {
console.log(item);
}John
Tom
Anna
Post 1
Post 2Hier haben wir definiert, dass wir zuerst über die Benutzer (users) und dann über die Beiträge (posts) iterieren möchten.
Wie funktioniert for...of intern?
Wenn JavaScript eine for...of Schleife sieht, passiert folgendes:
for (let item of myData) {
console.log(item);
}- JavaScript sucht nach
myData[Symbol.iterator] - Falls diese Property nicht existiert =>
TypeError: myData is not iterable - Falls sie existiert: JavaScript ruft
myData[Symbol.iterator]()auf - Das muss ein Iterator-Objekt zurückgeben (ein Objekt mit einer
next()Methode) - JavaScript ruft wiederholt
iterator.next()auf next()muss ein Objekt mit{ value: ..., done: ... }zurückgeben- Falls
doneden Werttruehat, beende die Schleife (Loop) - Falls
doneden Wertfalsehat, verwendevalueals Loop-Variable und iteriere weiter
Das kann man sich vereinfacht so vorstellen:
const iterator = myData[Symbol.iterator]();
while (true) {
const result = iterator.next();
if (result.done) break;
const item = result.value;
console.log(item);
}Generator-Funktionen und Symbol.iterator
Generator-Funktionen (Funktion mit function*) machen die Implementierung von Iteratoren viel einfacher.
const myData = {
users: ["John", "Tom", "Anna"],
posts: ["Post 1", "Post 2"],
*[Symbol.iterator]() {
// Viel einfacher mit Generator
yield* this.users;
yield* this.posts;
}
};
for (let item of myData) {
console.log(item);
}John
Tom
Anna
Post 1
Post 2Generator-Funktionen geben automatisch ein Iterator-Objekt zurück, das das next()-Protokoll implementiert. Jedes yield pausiert die Funktion und gibt einen Wert zurück.
Was passiert hier?
yield* this.usersist Syntax-Zucker für “yield jeden Wert aus demusers-Array”- Die Generator-Funktion merkt sich intern, wo sie war
- Bei jedem Aufruf von
next()läuft sie bis zum nächstenyieldweiter - Am Ende gibt sie automatisch
{ done: true }zurück
Spread-Operator und Destrukturisierung
Alles, was Symbol.iterator implementiert, funktioniert auch mit dem Spread-Operator und Destrukturisierung.
const myData = {
users: ["John", "Tom", "Alice"],
posts: ["Post 1", "Post 2"],
*[Symbol.iterator]() {
yield* this.users;
yield* this.posts;
}
};
const allItems = [...myData];
console.log(allItems);
const [first, second, ...rest] = myData;
console.log(first);
console.log(second);
console.log(rest);[ 'John', 'Tom', 'Alice', 'Post 1', 'Post 2' ]
John
Tom
[ 'Alice', 'Post 1', 'Post 2' ]Beide verwenden intern den Symbol.iterator Mechanismus.
Praktisches Beispiel - Range Iterator
Hier ein klassisches Beispiel - ein Range-Objekt, das über Zahlen iteriert.
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
*[Symbol.iterator]() {
for (let i = this.start; i <= this.end; i++) {
yield i;
}
}
}
const range = new Range(1, 5);
for (let num of range) {
console.log(num);
}
console.log([...range]);1
2
3
4
[ 1, 2, 3, 4 ]Diese Lösung ist elegant und lesbar. Ohne Symbol.iterator müssten wir ein Array erstellen und zurückgeben, was unnötigen Speicher verbraucht.
Symbol.toStringTag - Objekt-Identifikation
Wenn man Object.prototype.toString.call(obj) aufruft, bekommt man einen String wie [object Type]. Der “Type”-Teil wird durch Symbol.toStringTag bestimmt.
Das Problem
Standardmäßig haben alle Objekte denselben Typ.
class User {}
class Product {}
const user = new User();
const product = new Product();
console.log(Object.prototype.toString.call(user));
console.log(Object.prototype.toString.call(product));[object Object]
[object Object]Beide sagen nur “Object”, was nicht sehr hilfreich ist. Man kann nicht unterscheiden, was für ein Objekt es ist.
Die Lösung
Mit Symbol.toStringTag kann man einen benutzerdefinierten Tag setzen.
class User {
get [Symbol.toStringTag]() {
return "User";
}
}
class Product {
get [Symbol.toStringTag]() {
return "Product";
}
}
const user = new User();
const product = new Product();
console.log(Object.prototype.toString.call(user));
console.log(Object.prototype.toString.call(product));[object User]
[object Product]Jetzt kann man die Objekt unterscheiden.
Wie Object.prototype.toString() funktioniert
Intern macht Object.prototype.toString() ungefähr Folgendes.
// Vereinfachte Version
Object.prototype.toString = function() {
// 1. Versuche Symbol.toStringTag zu lesen
const tag = this[Symbol.toStringTag];
// 2. Falls vorhanden und ein String - verwende ihn
if (typeof tag === "string") {
return `[object ${tag}]`;
}
// 3. Sonst verwende den internen [[Class]]-Wert
// z.B. "Object", "Array", "Function"
const internalClass = getInternalClass(this);
return `[object ${internalClass}]`;
}Wo kann es nützlich sein?
Beispielsweise für Bibliotheken und Frameworks, um deren spezifische Objekte identifizierbar zu machen.
class ValidationError extends Error {
get [Symbol.toStringTag]() {
return "ValidationError";
}
toString() {
return `[object ${this[Symbol.toStringTag]}]`;
}
}
const err = new ValidationError("Invalid input");
console.log(err.toString());
console.log(`Fehler aufgetreten: ${err}`);[object ValidationError]
Fehler aufgetreten: [object ValidationError]Wichtig: In diesem Beispiel brauchen wir die toString() Methode direkt an der Klasse zu definieren, da sonst das Ergebnis anders aussehen wird, da wir die Error Klasse erweitern.
Würden wir nicht die Klasse Error erweitern, könnten wir ohne Umwege es schreiben/anwenden.
class ValidationError {
get [Symbol.toStringTag]() {
return "ValidationError";
}
}
const err = new ValidationError("Invalid input");
console.log(err.toString());
console.log(`Fehler aufgetreten: ${err}`);[object ValidationError]
Fehler aufgetreten: [object ValidationError]Built-in Beispiele
Viele Built-in Objekte verwenden Symbol.toStringTag.
console.log(Object.prototype.toString.call(new Map()));
console.log(Object.prototype.toString.call(new Set()));
console.log(Object.prototype.toString.call(new Promise(() => {})));
console.log(Object.prototype.toString.call(Math));
console.log(Object.prototype.toString.call(JSON));[object Map]
[object Set]
[object Promise]
[object Math]
[object JSON]Alle haben intern Symbol.toStringTag gesetzt.
Symbol.toPrimitive - Kontrolle über Type Coercion
Das vielleicht mächtigste Well-Known Symbol ist Symbol.toPrimitive. Es gibt dir vollständige Kontrolle darüber, wie dein Objekt in einen primitiven Wert konvertiert wird.
Das Type-Coercion-Problem
JavaScript konvertiert automatisch zwischen Typen, was manchmal verwirrend ist.
const obj = { value: 42 };
console.log(obj + 10);
console.log(+obj);[object Object]10
NaNBei beiden Ausgaben ist es sicherlich nicht das, was unsere Absicht gewesen wäre. JavaScript weiß allerdings nicht, wie ein Objekt in eine Zahl oder einen String konvertieren soll.
Die Standard-Konvertierung
Ohne Symbol.toPrimitive verwendet JavaScript einen zweistufigen Prozess.
- Für
Number-Kontext: VersuchevalueOf(), danntoString() - Für
String-Kontext: VersuchetoString(), dannvalueOf()
const obj = {
valueOf() {
return 42;
},
toString() {
return "Zweiundvierzig"
}
};
// valueOf wird verwendet
console.log(+obj);
// toString wird verwendet
console.log(`${obj}`);
// valueOf wird verwendet
console.log(obj + 0);
// valueOf wird verwendet, dann zu String konveriert
console.log(obj + "");42
Zweiundvierzig
42
"42"Das ist ein wenig kompliziert und nicht immer intuitiv.
Symbol.toPrimitive gibt uns die Kontrolle
Mit Symbol.toPrimitive bekommt man einen einzigen Einstiegspunkt mit voller Information.
const obj = {
[Symbol.toPrimitive](hint) {
console.log("hint:", hint);
if (hint === "number") {
return 42;
}
if (hint === "string") {
return "Zweiundvierzig";
}
return true;
}
};
console.log(+obj);
console.log(`${obj}`);
console.log(obj + "");
console.log(obj == true);hint: number
42
hint: string
Zweiundvierzig
hint: default
"true"
trueDie 3 Hints:
- number: Klare numerische Operationen
+obj(unäres Plus)obj - 5obj * 2Math.max(obj, 10)
- string: Klare String-Operationen
String(obj)${obj}(Template Literal)- Explizite String-Konvertierung
- default: Mehrdeutige Situationen
obj + value(könnte Zahl oder String sein)obj == valuenew Date(obj)
Der “default” Hint wird verwendet, wenn JavaScript nicht sicher ist, ob eine Zahl oder ein String erwartet wird. In den meisten Fällen sollte man “default” wie “number” behandeln, es sei denn man hat einen guten Grund, etwas anderes zu tun.
Praktisches Beispiel - Money-Klasse
Hier ein Beispiel, in dem Symbol.toPrimitive ziemlich nützlich ist.
class Money {
constructor(amount, currency = "EUR") {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
if (hint === "number") {
return this.amount;
}
if (hint === "string") {
return `${this.amount.toFixed(2) ${this.currency}}`;
}
return this.amount;
}
}
const price = new Money(98.5);
// Als Zahl: 98.5
console.log(+price); // 98.5
console.log(price * 2); // 197
// Als String: "98.50 EUR"
console.log(`Preis: ${price}`); // Preis: 98.50 EUR
console.log(String(price)); // 98.50 EUR
// Vergleich (default hint)
console.log(price > 50); // true
console.log(price == 98.5); // true
// Mathematik
const total = price + new Money(10.5);
console.log(total); // 10998.5
197
Preis: 98.50 EUR
98.50 EUR
true
true
109Somit haben wir dieses Objekt so aufgestellt, dass es sich in mathematischen Kontexten wie eine Zahl und in String-Kontexten wie ein formatierter String verhält.
Wie Engine Symbol.toPrimitive verwendet
Wenn JavaScript ein Objekt zu einem Primitive konvertieren muss, läuft intern ein Algorithmus ab, der ungefähr so funktioniert:
- Prüfung: Ist der Wert bereits ein Primitive? Falls ja, gib ihn zurück.
Symbol.toPrimitivesuchen: Existiertobj[Symbol.toPrimitive]?- Falls ja: Rufe die Methode mit dem passenden Hint auf.
- Validierung: Ist das Ergebnis ein Primitive? Falls ja, gib es zurück. Falls nein, wirf TypeError.
- Falls
Symbol.toPrimitivenicht existiert: Verwende den Standard-Algorithmus (valueOf/toString)
Der Schlüsselpunkt: Symbol.toPrimitive hat Priorität über valueOf und toString(). Wenn es existiert, werden die anderen Methoden ignoriert.
Symbol.hasInstance - Instanceof anpassen
Symbol.hasInstance gibt uns Kontrolle über den instanceof Operator.
Der Standard instanceof
Normalerweise prüft instanceof die Prototype-Chain.
class Animal {}
class Dog extends Animal {}
const dog = new Dog();
console.log(dog instanceof Dog);
console.log(dog instanceof Animal);
console.log(dog instanceof Object);true
true
trueDie Prüfung läuft die Prototype-Chain hoch und schaut, ob Dog.prototype, Animal.prototype, etc. in der Chain sind.
Custom instanceof mit Symbol.hasInstance
Man kann dieses Verhalten komplett überschreiben.
class MyArray {
static [Symbol.hasInstance](instance) {
return Array.isArray(instance);
}
}
console.log([] instanceof MyArray);
console.log({} instanceof MyArray);
console.log([1, 2, 3] instanceof MyArray);true
false
trueObwohl [] nicht von MyArray erbt, gibt instanceof true zurück, weil wir die Prüfung selbst definiert haben.
Hier ein weiteres Beispiel mit Type Checking.
class Numeric {
static [Symbol.hasInstance](instance) {
return typeof instance === "number" ||
typeof instance === "bigint" ||
instance instanceof Number;
}
}
console.log(42 instanceof Numeric);
console.log(42n instanceof Numeric);
console.log(new Number(42) instanceof Numeric);
console.log("42" instanceof Numeric);true
true
true
falseDas erlaubt bequeme Type-Checks für “duck-typed” Konzepte.
Praktische Beispiele
Private Properties (Bibliotheks-Kontext)
Vor der #private Syntax wurden die Symbole für private Properties verwendet. Auch heute sind sie noch nützlich für Bibliotheken, die nicht auf neueste JS-Features angewiesen sein wollen/können.
const _balance = Symbol("balance");
const _transactions = Symbol("transactions");
class BankAccount {
constructor(initialBalance) {
this[_balance] = initialBalance;
this[_transactions] = [];
}
deposit(amount) {
if (amount <= 0) {
throw new Error("Menge muss positiv sein");
}
this[_balance] += amount;
this[_transactions].push({
type: "deposit",
amount,
date: new Date()
});
}
withdraw(amount) {
if (amount > this[_balance]) {
throw new Error("Nicht genügend Guthaben");
}
this[_balance] -= amount;
this[_transactions].push({
type: "withdraw",
amount,
date: new Date()
});
}
get balance() {
return this[_balance];
}
getTransactionHistory() {
return [...this[_transactions]];
}
}
const account = new BankAccount(1000);
account.deposit(400);
account.withdraw(300);
console.log("New balance:", account.balance);
// Nicht zugänglich von außen
console.log("account._balance", account._balance);
// Zugänglich, weil im gleichen Scope
console.log("account[_balance]", account[_balance]);
// Aber sichtbar für Debuggung
const symbols = Object.getOwnPropertySymbols(account);
console.log(account[symbols[0]]);New balance: 1100
account._balance => undefined
account[_balance] => 1100
1100Metadaten ohne Kollision
In modernen Frameworks ist es üblich, Metadaten an Objekten zu speichern. Symbole sind sehr gut dafür geeignet.
// React-ähnliches System
const ELEMENT_TYPE = Symbol.for("react.element");
const MEMO_TYPE = Symbol.for("react.memo");
const FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
function createElement(type, props, children) {
return {
$$typeof: ELEMENT_TYPE,
type,
props: { ...props, children }
};
}
function memo(component) {
return {
$$typeof: MEMO_TYPE,
type: component
};
}
function isValidElement(obj) {
return (
typeof obj === "object" &&
obj !== null &&
obj.$$typeof === ELEMENT_TYPE
);
}
function isMemoComponent(obj) {
return (
typeof obj === "object" &&
obj !== null &&
obj.$$typeof === MEMO_TYPE
);
}
// Verwendung
const MyComponent = () => createElement("div", {}, "Hello");
const MemoizedComponent = memo(MyComponent);
console.log(isValidElement(MyComponent()));
console.log(isMemoComponent(MemoizedComponent));true
trueDas $$typeof verhindert, dass User-Daten als React-Elemente interpretiert werden.
Iterierbares Objekt
const company = {
name: "Sample company",
employees: ["John", "Tom", "Alice"],
[Symbol.iterator]() {
let i = 0;
const employees = this.employees;
return {
next() {
if (i < employees.length) {
return {
value: employees[i++],
done: false
};
} else {
return {
done: true
};
}
}
};
}
};
for (const name of company) {
console.log(name);
}John
Tom
AliceIn diesem Beispiel haben wir eigene Art und Weise definiert, wie über das Objekt iteriert werden kann.
Kontrolle über Typ-Konvertierung
const product = {
name: "Laptop",
price: 1200,
[Symbol.toPrimitive](hint) {
if (hint === "string") return this.name;
if (hint === "number") return this.price;
return `${this.name} ${this.price} €`;
}
};
console.log(String(product));
console.log(+product);
console.log(product + "");Laptop
1200
Laptop 1200 €Hiermit haben wir verschiedene Möglichkeiten geschaffen, ein Objekt zu einer “primitiven”, oder besser gesagt “einfachen”, Ausgabe zu konvertieren.
Interne Flags
const IS_INTERNAL = Symbol("internal");
function createUser(name) {
return {
name,
role: "user",
[IS_INTERNAL]: true
};
}
const user = createUser("John");
console.log(Object.keys(user));
console.log(user[IS_INTERNAL]);[ 'name', 'role' ]
trueWir können Symbole dafür verwenden, um bestimmte Eigenschaften von Standard-Verarbeitung wie Object.keys (also Standard-Sichtbarkeit) zu verstecken.