Course outline · 0% complete

0/27 lessons0%

Course overview →

Symbols: keys that cannot collide

lesson 7-3 · ~10 min · 19/27

Symbols: keys that cannot collide

Lesson 7-1 leaned on Symbol.iterator, so now it is worth making symbols themselves precise.

A symbol is a primitive value whose entire job is to be unique. Every Symbol("desc") call mints a brand-new value equal to nothing else, ever, and the string is only a debugging label.

The language needed them because property keys used to be strings only.

When a library wants to stash bookkeeping on your object, or the language wants to give objects hook methods like the iterator hook, a string key could collide with your real data.

A symbol key cannot collide, and the reason is stronger than convention. Code that does not hold the symbol value cannot even name the property.

Symbol-keyed properties also stay out of normal enumeration. Object.keys, for...in, and JSON.stringify all skip them, so attached metadata never leaks into your loops or API payloads.

They are not truly private, and that is worth being honest about. Object.getOwnPropertySymbols(obj) lists them, so symbols prevent accidents rather than enforce secrecy the way a closure does.

Uniqueness and invisibility

Two symbols with the same label are still different values.

const id1 = Symbol("id");
const id2 = Symbol("id");
console.log(id1 === id2);

const user = { name: "Ada" };
user[id1] = 123;

console.log(user[id1]);
console.log(JSON.stringify(user));
console.log(Object.keys(user));

Output

false
123
{"name":"Ada"}
[ 'name' ]

The symbol-keyed property is readable with the symbol in hand, and invisible to JSON.stringify and Object.keys.

user[id2] would be undefined, since id2 is a different key despite the identical label.

Symbol is called without new, and new Symbol() actually throws. It is a primitive, like a number or a string, not an object.

Symbols do not coerce to strings implicitly either. "id: " + id1 throws a TypeError, and String(id1) gives "Symbol(id)" when you want the label for a log.

Because the property is skipped by serialization, a symbol is a reasonable place to hang a cache or a computed flag on an object you do not fully own.

Well-known symbols: the language's hook points

The language defines a set of well-known symbols, stored as properties of Symbol itself, that act as hook points. Put a method under one of these keys and built-in machinery will call it.

Symbol.iterator from lesson 7-1 is the famous one, and it is how for...of asks an object how to walk it.

SymbolHooks into
Symbol.iteratorfor...of, spread, destructuring
Symbol.asyncIteratorfor await...of
Symbol.hasInstanceinstanceof
Symbol.toPrimitivecoercion to number or string

These are not values you invent. They already exist on Symbol, and your job is to supply the method under the key.

The cleanest way to implement Symbol.iterator is a generator method, which is lesson 7-2's function* written as a method by putting the * in front of the computed name.

That combination is the payoff for this whole unit. No hand-rolled next(), no { value, done } bookkeeping, and every iterable-consuming construct in the language starts working on your type.

It evaluates to false, because every Symbol() call creates a unique value and the description is just a label.

Uniqueness is the whole point. Each call mints a fresh value, so holding the symbol is the only way to reach its property.

That also means a symbol has to be stored somewhere to be usable. A symbol created inline as obj[Symbol("id")] = 1 sets a property nothing can ever read again.

When you genuinely want the same symbol back for the same string, a separate registry exists. Symbol.for("id") === Symbol.for("id") is true, because Symbol.for looks the key up in a global table instead of minting a new value.

Symbol.keyFor(sym) goes the other way and returns the registry string, or undefined for a symbol that was never registered.

The choice between them is a scoping decision. Use Symbol() for a key private to your module, and Symbol.for when two independent pieces of code must agree on the same key.

A generator method as the iterator hook

The * and the brackets combine in one method definition.

const range = {
  start: 1,
  end: 3,
  *[Symbol.iterator]() {
    for (let n = this.start; n <= this.end; n++) {
      yield n;
    }
  },
};

for (const n of range) {
  console.log(n);
}
console.log([...range].join("+"));

Output

1
2
3
1+2+3

The syntax is *[Symbol.iterator]() { ... }, where the * makes it a generator and the brackets make the key computed.

Compare this to the hand-written version in lesson 7-1, which needed a closure variable, a next method, and two { value, done } objects. This is the same behavior in four lines.

this.start and this.end work because it is a real method, so this is range by the method-call rule from unit 2.

The final line uses ... to expand the iterable, which is unit 8's spread, and it works on anything iterable including this object.

Each for...of or spread calls the method again and gets a fresh generator, so range can be iterated any number of times.

Changing range.end to 5 immediately changes what iteration produces, since the bound is read when the generator runs rather than when the object is defined.