Symbols inside the engine
Learn how V8 stores JavaScript symbols, shares registry entries, uses well-known hooks, and hides private names and brands.
- 01Read a V8 Symbol debug printIdentify the symbol map, random hash, description, flags, and why two equal descriptions still produce different identities.
- 02Separate local, registered, and well-known symbolsExplain which symbols are shared across realms, which live in read-only roots, and which ones can be WeakMap keys.
- 03Connect symbols to object internalsTrace symbol-keyed properties, interesting-symbol map flags, and the private-name symbols used by class fields and brands.
Symbols are identity objects
A JavaScript symbol is a primitive value at the language level, but V8 represents it with a small heap object. The object's address is the identity, so comparing two symbols is a pointer comparison. The description is just a label for humans and tools.
A symbol is an opaque identity value. In V8 12.4 that identity lives in a Symbol heap object with a map, random hash, flags, and an optional description string or undefined.
const first = Symbol("id");const second = Symbol("id");console.log(first === second);console.log(String(first));Lines 1 and 2 use the same description. Line 3 still prints false, because the two heap objects are different identities. Line 4 is allowed because String(symbol) is an explicit conversion.
Two people can both be named Asha without being the same person. The name is useful when talking about them, but it does not make them identical. Symbols work the same way: the description is visible, but identity is private.
- In real life: Two people both named Asha
- In JavaScript: Two
Symbol('id')descriptions - In real life: They are still different people
- In JavaScript: V8 compares symbol identity
- In real life: The name helps people talk about them
- In JavaScript: The description helps debugging and display
Where the analogy stops: People have many traits beyond a name. JavaScript cannot derive a symbol's identity from its description text.
If you want the beginner surface first, read Symbols and Well-known symbols. This lesson stays inside V8 and cites the V8 12.4.254.21 source, the ECMAScript spec, local Node probes, and the V8 class-features blog.
Hash, flags, and description
V8 declares class Symbol : public Name in src/objects/name.h. The class has a flags_ bitfield and a description_ field whose comment says String|Undefined. The real flag names in V8 12.4 are listed below.
| Flag | Meaning in this lesson |
|---|---|
is_private | Internal private symbols and private names use this bit. |
is_private_name | Private fields use private-name symbols; missing access throws instead of acting like an ordinary missing property. |
is_private_brand | Private methods/accessors store a brand symbol whose value is the class context. |
is_in_public_symbol_table | Set by Symbol.for; Symbol.keyFor can return the description string directly. |
is_interesting_symbol | Set for Symbol.toPrimitive and Symbol.toStringTag in V8 12.4. |
is_well_known_symbol | V8's access-check bit for a small internal list, not every ECMAScript well-known symbol. |
The allocation path is in src/heap/factory.cc. Factory::NewSymbolInternal allocates with the read-only symbol_map, calls GenerateIdentityHash, stores that random hash in the raw hash field, sets the description to undefined, and clears the flags. factory.h says runtime symbols default to AllocationType::kOld, so Node debug output says in OldSpace. It is old space, not young space, because the factory explicitly disallows young allocation for symbols.
%DebugPrint proofJavaScript// Node-only: run with --allow-natives-syntax.function print(label, value) { process.stdout.write("@@" + label + "\n"); %DebugPrint(value);} const first = Symbol("id");const second = Symbol("id");print("first", first);print("second", second);print("empty", Symbol());print("registered", Symbol.for("app.id"));The test asserts only stable text: [Symbol] in OldSpace, type: SYMBOL_TYPE, description: ... #id, the flag names, and two different hash: lines for two Symbol("id") values. It never asserts addresses or map sizes; Node on this machine has pointer compression off, while Chrome uses pointer compression and a newer V8.
The global symbol registry
Symbol.for does not use the description as a normal local identity. V8's builtins-symbol.cc calls isolate->SymbolFor(RootIndex::kPublicSymbolTable, key, false). Isolate::SymbolFor internalizes the string key, looks it up in a RegisteredSymbolTable, creates a symbol when missing, and sets is_in_public_symbol_table for the public table. The table shape is declared in hash-table.h.
// Node-only proof that uses Node's vm module.import vm from "node:vm";console.log(vm.runInNewContext('Symbol.for("app.id")') === Symbol.for("app.id"));console.log(vm.runInNewContext("Symbol.iterator") === Symbol.iterator);console.log(Symbol.keyFor(Symbol.for("app.id")));console.log(Symbol.keyFor(Symbol("app.id")));The first two lines print true: Node's new vm context sees the same public registry entry for Symbol.for("app.id"), and the same read-only Symbol.iterator root. ECMAScript's well-known symbols section says well-known symbols are shared by all realms.
Weak collections use the spec's CanBeHeldWeakly operation. Objects are weak keys, and symbols are weak keys only when they are not registered symbols. That is why a local symbol and Symbol.iterator work, while a registry symbol throws.
for (const value of [Symbol("local"), Symbol.iterator, Symbol.for("shared")]) { try { new WeakMap().set(value, "stored"); console.log(String(value), "accepted"); } catch (error) { console.log(String(value), error.name + ": " + error.message); }}Well-known symbols in read-only space
During heap setup, V8 allocates private, public, and well-known root symbols with AllocationType::kReadOnly in setup-heap-internal.cc. The root lists come from heap-symbols.h and are included in the read-only roots in roots.h. That is the same snapshot idea covered in Startup performance: roots can be built once, then shared by new contexts.
// Node-only: run with --allow-natives-syntax.function print(label, value) { process.stdout.write("@@" + label + "\n"); %DebugPrint(value);} for (const name of [ "iterator", "hasInstance", "isConcatSpreadable", "toPrimitive", "toStringTag",]) { print(name, Symbol[name]);}Be precise about V8's names. ECMAScript calls values such as Symbol.iterator and Symbol.toPrimitive well-known symbols. V8's internal is_well_known_symbol bit is narrower: in Node 22/V8 12.4 the debug probe shows the bit for Symbol.hasInstance, Symbol.isConcatSpreadable, and Symbol.toStringTag, but not for every spec well-known symbol. Symbol.toPrimitive is read-only and interesting, but its V8 well-known bit is 0.
// Node-only: run with --allow-natives-syntax.const array = [];array.push(1.5);%DebugPrint(array);The array probe prints a map transition keyed by <Symbol: (elements_transition_symbol)>. That is one of V8's many private internal symbols; it is not a JavaScript property key you can obtain from your program.
Interesting symbols and lookup shortcuts
V8 marks Symbol.toPrimitive and Symbol.toStringTag with is_interesting_symbol during heap setup. Name::IsInteresting also treats the strings toJSON and get as interesting. When a descriptor or dictionary entry with an interesting name is added, map-inl.h and js-objects.cc set the map or dictionary's may_have_interesting_properties bit.
// Node-only: run with --allow-natives-syntax.function print(label, value) { process.stdout.write("@@" + label + "\n"); %DebugPrint(value);} print("plain", {});print("toPrimitive", { [Symbol.toPrimitive]() { return 7; } });print("toStringTag", { [Symbol.toStringTag]: "Tagged" });V8 12.4 source model:Name::IsInteresting() is true for symbols whose is_interesting_symbol bit is set,and also for the strings "toJSON" and "get". Map::AppendDescriptor and dictionaryinsertion copy that fact into may_have_interesting_properties. JSON.stringify walksthe receiver and prototypes with MayHaveInterestingProperties() before it pays forthe toJSON lookup.The explicit source check in V8 12.4 is in json-stringifier.cc: MayHaveInterestingProperties walks the receiver and prototype maps. If none may have interesting properties, JSON can skip the expensive toJSON lookup. JSReceiver::ToPrimitive still calls Object::GetMethod for Symbol.toPrimitive in js-objects.cc; the replay labels its shortcut as a source-based model, not a live trace.
Replay how a well-known symbol lets an object participate in ToPrimitive.
script
valueOf() { return 10; },};const protocol = { [Symbol.toPrimitive](hint) { return hint === "string" ? "tagged" : 7; },};console.log(+plain);console.log(+protocol);console.log(`${protocol}`);Private names and brands
Private fields reuse the property machinery without becoming public properties. The V8 blog post Faster initialization of instances with new class features says V8 implements private fields with internal private symbols. The same post explains that optimized field initialization records the private-name symbol in feedback with hidden-class transitions.
// Node-only: run with --allow-natives-syntax.class FieldBox { #x = 1; read() { return this.#x; }}class MethodBox { #m() { return 1; } call() { return this.#m(); }}%DebugPrint(new FieldBox());%DebugPrint(new MethodBox());The stable debug text is the important part: an instance with #x prints an in-object property keyed by <Symbol: #x>. A class with a private method prints a hidden brand slot such as <Symbol: MethodBox> whose value is the class context. V8's Runtime_AddPrivateBrand stores that brand as a non-enumerable, non-configurable, read-only own property.
class FieldBox { #x = 1; read() { return this.#x; } static hasBrand(value) { return #x in value; }}const box = new FieldBox();console.log(Reflect.ownKeys(box).length);console.log(Object.getOwnPropertySymbols(box).length);console.log(FieldBox.hasBrand(box));console.log(FieldBox.hasBrand(new Proxy(box, {})));try { box.read.call(new Proxy(box, {}));} catch (error) { console.log(error.name + ": " + error.message);}try { FieldBox.hasBrand(null);} catch (error) { console.log(error.name + ": " + error.message);}The brand check #x in value returns true for the real instance and false for a proxy wrapper, because the proxy object itself does not carry the private name. Calling a method through the proxy throws a TypeError when it tries to read #x. Checking #x in null also throws because the right side is not an object.
Symbols as property keys
V8's descriptor arrays and property dictionaries use Name keys, and Name covers strings and symbols. That is why a symbol can be an own property key without becoming a string. The ECMAScript OrdinaryOwnPropertyKeys algorithm orders own keys as integer indexes, then strings, then symbols.
Replay how symbol keys live on objects while ordinary enumeration skips them.
script
const record = { 2: "two", a: "letter", [secret]: "hidden", 1: "one" };console.log(Object.keys(record).join("|"));console.log(Object.getOwnPropertySymbols(record).map(String).join("|"));console.log(Reflect.ownKeys(record).map(String).join("|"));console.log(JSON.stringify(record));Object.keys, for...in, and JSON.stringify skip symbol-keyed properties. Use Object.getOwnPropertySymbols when you want only symbols, or Reflect.ownKeys when you want strings and symbols.
Build and test a symbol
Change the description and factory. Then compare two symbols, use the symbol as a property key, try it as a WeakMap key, and test string conversion. The modeled flags summarize what the V8 probes prove for runtime symbols.
const made = Symbol("app.id");const twin = Symbol("app.id");console.log(made === twin);const bag = { [made]: "value" };console.log(bag[made]);try { new WeakMap().set(made, "stored"); console.log("weak key accepted");} catch (error) { console.log(error.name + ": " + error.message);}try { console.log("" + made);} catch (error) { console.log(error.name + ": " + error.message);}console.log(String(made));falsevalueweak key accepted"" + symbolTypeError: Cannot convert a Symbol value to a stringString(symbol)Symbol(app.id)Modelled V8 flags
- description
- app.id
- runtime allocation
- OldSpace in Node/V8 12.4 for symbols created while the program runs
- public registry flag
- not set
- weak-key allowed
- yes, unregistered symbols are weak keys
Symbol creates a fresh unregistered symbol. The matching description does not make the twin equal, and the symbol is accepted as a WeakMap key.
Practical use
Symbols are practical when you need a collision-free key or a protocol hook. Use local symbols for private-ish library metadata, registered symbols only when separate code really must rendezvous by a shared string, and well-known symbols when you intentionally implement a language protocol such as iteration or primitive conversion.
- Use a local symbol key to avoid colliding with user-provided string keys.
- Do not use the description as identity; two local symbols with the same description are different.
- Prefer private fields for true class privacy. Symbol keys are discoverable by reflection unless they are V8 private names.
- Measure before assuming a symbol hook is slow; the interesting-symbol bit exists because engines optimize common missing-hook lookups.
A shared contact lets everyone find the same person. A contact saved only on your phone stays separate, and a private note stays hidden from the normal contact list. These are the three kinds of symbol access in this lesson.
- In real life: A shared contact everyone can find
- In JavaScript: A
Symbol.forregistry key - In real life: A contact saved only on your phone
- In JavaScript: A local
Symbol()identity - In real life: A private note on one contact
- In JavaScript: A private-name symbol for
#x
Where the analogy stops: Phone contacts are ordinary visible data. A symbol description is not a lookup key unless you choose the global registry.
Common misconceptions
- “The description is the identity.” No. It is a label; the heap object identity is what compares equal.
- “All
Symbol.*values have V8's well-known bit.” No. V8's bit is a narrow internal access-check flag. - “Symbol keys are invisible.” Ordinary symbol keys are skipped by common enumeration, but
Reflect.ownKeyscan list them. - “Registered symbols make good WeakMap keys.” No. The spec excludes registered symbols from weak keys.
- “Private fields are just normal symbols.” They use private-name symbols internally, but public reflection and bracket access cannot reach them.
| Kind | Where it lives | Shared across realms? | Weak-key allowed? | Visible to reflection? |
|---|---|---|---|---|
Symbol() | Runtime Symbol object in old space | No | Yes | Visible through Object.getOwnPropertySymbols when used as a property key |
Symbol.for() | Runtime Symbol object stored in V8's public registered symbol table | Yes, inside one agent / isolate | No | Visible as a normal symbol key; Symbol.keyFor returns the registry string |
| Spec well-known symbol | Read-only root allocated during heap setup | Yes | Yes | Visible if you put it on an object; V8's internal is_well_known_symbol bit covers only a subset |
| Private name / brand | Private V8 symbol used by class fields or methods | Class-scoped, not a public registry entry | Engine internal | Hidden from Reflect.ownKeys and Object.getOwnPropertySymbols |
Symbol('id')Symbol.for('id')Symbol.iteratorSymbol.toPrimitive#xfield key- Private method brand
'id'description string- Object key
'toString'
Place each card by the V8 or language category it belongs to.
Practice exercises
6 EXERCISESWhat does the comparison print?
const a = Symbol("id");
const b = Symbol("id");
console.log(a === b);It prints false. The two symbols have the same label but different identities.
Write the two outputs in order.
const a = Symbol.for("app.id");
const b = Symbol.for("app.id");
console.log(a === b);
console.log(Symbol.keyFor(a));The program prints true and then app.id. Both calls return the same registered symbol.
Which kind of symbol is rejected as a WeakMap key?
for (const value of [Symbol("local"), Symbol.iterator, Symbol.for("shared")]) {
try {
new WeakMap().set(value, "stored");
console.log(String(value), "accepted");
} catch (error) {
console.log(String(value), error.name + ": " + error.message);
}
}The registered symbol from Symbol.for throws. CanBeHeldWeakly excludes registered symbols.
What does the Reflect.ownKeys line print?
const secret = Symbol("secret");
const record = { 2: "two", a: "letter", [secret]: "hidden", 1: "one" };
console.log(Object.keys(record).join("|"));
console.log(Object.getOwnPropertySymbols(record).map(String).join("|"));
console.log(Reflect.ownKeys(record).map(String).join("|"));
console.log(JSON.stringify(record));The all-key order is 1|2|a|Symbol(secret).
How many keys do Reflect.ownKeys and Object.getOwnPropertySymbols report?
class FieldBox {
#x = 1;
read() { return this.#x; }
static hasBrand(value) { return #x in value; }
}
const box = new FieldBox();
console.log(Reflect.ownKeys(box).length);
console.log(Object.getOwnPropertySymbols(box).length);
console.log(FieldBox.hasBrand(box));
console.log(FieldBox.hasBrand(new Proxy(box, {})));
try {
box.read.call(new Proxy(box, {}));
} catch (error) {
console.log(error.name + ": " + error.message);
}
try {
FieldBox.hasBrand(null);
} catch (error) {
console.log(error.name + ": " + error.message);
}The two reflection calls find 0 and 0 keys. The private field is present internally but hidden from those APIs.
Which conversion is allowed?
const tag = Symbol("tag");
try {
console.log("" + tag);
} catch (error) {
console.log(error.name + ": " + error.message);
}
console.log(String(tag));Use String(symbol). JavaScript rejects implicit symbol-to-string concatenation, but explicit String is allowed.
Quiz: check your understanding
9 QUESTIONSQuestion 1 of 9What is the identity of a normal JavaScript symbol in V8's model?
Choose an answer to see the explanation.
Question 2 of 9What does this print?
Read the code, then predictconsole.log(Symbol("id") === Symbol("id"));Choose an answer to see the explanation.
Question 3 of 9What does the registry code print?
Read the code, then predictconsole.log(Symbol.for("app.id") === Symbol.for("app.id")); console.log(Symbol.keyFor(Symbol.for("app.id")));Choose an answer to see the explanation.
Question 4 of 9Which value cannot be used as a WeakMap key according to this lesson and the spec?
Read the code, then predictfor (const value of [Symbol("local"), Symbol.iterator, Symbol.for("shared")]) { try { new WeakMap().set(value, "stored"); console.log(String(value), "accepted"); } catch (error) { console.log(String(value), error.name + ": " + error.message); } }Choose an answer to see the explanation.
Question 5 of 9What does this ordering code print for all own keys?
Read the code, then predictconst secret = Symbol("secret"); const record = { 2: "two", a: "letter", [secret]: "hidden", 1: "one" }; console.log(Object.keys(record).join("|")); console.log(Object.getOwnPropertySymbols(record).map(String).join("|")); console.log(Reflect.ownKeys(record).map(String).join("|")); console.log(JSON.stringify(record));Choose an answer to see the explanation.
Question 6 of 9Which V8 12.4 symbols are marked
is_interesting_symbolin the lesson's probes?Choose an answer to see the explanation.
Question 7 of 9What does this private-field reflection code print first?
Read the code, then predictclass Box { #x = 1; } const box = new Box(); console.log(Reflect.ownKeys(box).length); console.log(Object.getOwnPropertySymbols(box).length);Choose an answer to see the explanation.
Question 8 of 9Which conversion is deliberately rejected?
Read the code, then predictconst tag = Symbol("tag"); try { console.log("" + tag); } catch (error) { console.log(error.name + ": " + error.message); } console.log(String(tag));Choose an answer to see the explanation.
Question 9 of 9What does the
Symbol.toPrimitivehook return for numeric conversion in the replay?Read the code, then predictconst plain = { valueOf() { return 10; }, }; const protocol = { [Symbol.toPrimitive](hint) { return hint === "string" ? "tagged" : 7; }, }; console.log(+plain); console.log(+protocol); console.log(`${protocol}`);Choose an answer to see the explanation.
Key takeaways
- A V8 symbol is a small heap object; identity is pointer identity, not description text.
- Runtime symbols default to old-space allocation and receive a random identity hash at creation.
Symbol.foruses V8's public registered symbol table; registered symbols are not WeakMap keys.- Well-known symbols are read-only roots, but V8's
is_well_known_symbolbit is narrower than the spec term. - Private fields and methods use private-name and private-brand symbols that public reflection cannot list.
One-liner.
A symbol is a heap-backed identity token whose label can be shared or hidden, while the engine uses flags to make common symbol protocols fast and private names invisible.
Up next: Hidden classes & object shapes, where those descriptor arrays, map flags, and transitions become the main story.