cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Symbols inside the engine

Learn how V8 stores JavaScript symbols, shares registry entries, uses well-known hooks, and hides private names and brands.

By the end, you can
  • 01
    Read a V8 Symbol debug printIdentify the symbol map, random hash, description, flags, and why two equal descriptions still produce different identities.
  • 02
    Separate 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.
  • 03
    Connect 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.

Definition

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.

Same description, different identityPop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogyTwo people both named Asha

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.

V8 12.4 symbol flags
FlagMeaning in this lesson
is_privateInternal private symbols and private names use this bit.
is_private_namePrivate fields use private-name symbols; missing access throws instead of acting like an ordinary missing property.
is_private_brandPrivate methods/accessors store a brand symbol whose value is the class context.
is_in_public_symbol_tableSet by Symbol.for; Symbol.keyFor can return the description string directly.
is_interesting_symbolSet for Symbol.toPrimitive and Symbol.toStringTag in V8 12.4.
is_well_known_symbolV8'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.

Node-only %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"));
Stable lines to trust

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 registry and realm proofJavaScript
// 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.

WeakMap keys: local, well-known, registeredPop out in the code editor (opens in a new tab)JavaScript
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 well-known symbol flagsJavaScript
// 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 map transition with a private internal symbolJavaScript
// 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 interesting-symbol map bitJavaScript
// 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" });
Where the shortcut is checkedText
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 Symbol.toPrimitive lookup
Step 0 of 8Ready
Your turn: follow the blue line

Replay how a well-known symbol lets an object participate in ToPrimitive.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  valueOf() {    return 10;  },};const protocol = {  [Symbol.toPrimitive](hint) {    return hint === "string" ? "tagged" : 7;  },};console.log(+plain);console.log(+protocol);console.log(`${protocol}`);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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 private field and brand debug printJavaScript
// 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.

Private names are not public reflection keysPop out in the code editor (opens in a new tab)JavaScript
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 symbol-key enumeration
Step 0 of 6Ready
Your turn: follow the blue line

Replay how symbol keys live on objects while ordinary enumeration skips them.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Playground: build a symbol and use it
Generated symbol experimentPop out in the code editor (opens in a new tab)JavaScript
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));
Real resultslocal
same factory twicefalse
symbol key readvalue
WeakMap keyweak key accepted
"" + symbolTypeError: Cannot convert a Symbol value to a string
String(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
Try it yourself
Factory

Symbol creates a fresh unregistered symbol. The matching description does not make the twin equal, and the symbol is accepted as a WeakMap key.

The left pane is runnable JavaScript. The flag list is a model backed by the Node/V8 debug-print probes in the tests.

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.
Real-life analogyNames in a phone contact list

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.for registry 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.ownKeys can 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.
Four symbol-like things that are easy to mix up
KindWhere it livesShared across realms?Weak-key allowed?Visible to reflection?
Symbol()Runtime Symbol object in old spaceNoYesVisible through Object.getOwnPropertySymbols when used as a property key
Symbol.for()Runtime Symbol object stored in V8's public registered symbol tableYes, inside one agent / isolateNoVisible as a normal symbol key; Symbol.keyFor returns the registry string
Spec well-known symbolRead-only root allocated during heap setupYesYesVisible if you put it on an object; V8's internal is_well_known_symbol bit covers only a subset
Private name / brandPrivate V8 symbol used by class fields or methodsClass-scoped, not a public registry entryEngine internalHidden from Reflect.ownKeys and Object.getOwnPropertySymbols
Sort the symbol internals
  • Symbol('id')
  • Symbol.for('id')
  • Symbol.iterator
  • Symbol.toPrimitive
  • #x field key
  • Private method brand
  • 'id' description string
  • Object key 'toString'
Try it yourself
0 of 8 correct

Place each card by the V8 or language category it belongs to.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Practice exercises

6 EXERCISES
Exercise 1 · Warm-upPredict local identity

What does the comparison print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const a = Symbol("id");
const b = Symbol("id");
console.log(a === b);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the registry output

    Write the two outputs in order.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const a = Symbol.for("app.id");
    const b = Symbol.for("app.id");
    console.log(a === b);
    console.log(Symbol.keyFor(a));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the rejected WeakMap key

      Which kind of symbol is rejected as a WeakMap key?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);
        }
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRemember property-key order

        What does the Reflect.ownKeys line print?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeInspect private reflection

          How many keys do Reflect.ownKeys and Object.getOwnPropertySymbols report?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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);
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeChoose the safe string conversion

            Which conversion is allowed?

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            const tag = Symbol("tag");
            try {
              console.log("" + tag);
            } catch (error) {
              console.log(error.name + ": " + error.message);
            }
            console.log(String(tag));

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz: check your understanding

              9 QUESTIONS
              Symbols inside the engine quiz · 9 questionsScore: first tries count
              1. Question 1 of 9What is the identity of a normal JavaScript symbol in V8's model?

                Choose an answer to see the explanation.

              2. Question 2 of 9What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                console.log(Symbol("id") === Symbol("id"));

                Choose an answer to see the explanation.

              3. Question 3 of 9What does the registry code print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                console.log(Symbol.for("app.id") === Symbol.for("app.id"));
                console.log(Symbol.keyFor(Symbol.for("app.id")));

                Choose an answer to see the explanation.

              4. Question 4 of 9Which value cannot be used as a WeakMap key according to this lesson and the spec?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                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);
                  }
                }

                Choose an answer to see the explanation.

              5. Question 5 of 9What does this ordering code print for all own keys?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                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));

                Choose an answer to see the explanation.

              6. Question 6 of 9Which V8 12.4 symbols are marked is_interesting_symbol in the lesson's probes?

                Choose an answer to see the explanation.

              7. Question 7 of 9What does this private-field reflection code print first?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                class 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.

              8. Question 8 of 9Which conversion is deliberately rejected?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.

              9. Question 9 of 9What does the Symbol.toPrimitive hook return for numeric conversion in the replay?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const 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.for uses 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_symbol bit 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.

              CompleteFrontend Clear concepts. Working examples.