Symbols and Well-Known Symbols

Mental model: A symbol is a key that no other code can ever guess or recreate, which makes it the only safe place to store metadata on an object you do not own.

Level: advanced · about 16 minutes

Two libraries both want to hang a cache on your objects. One writes obj._cache, the other writes obj.__cache. One day they pick the same name and quietly corrupt each other. Symbols exist so that never happens: a symbol is a primitive whose entire purpose is to be unequal to every other value in the program, including another symbol with the same description.

const a = Symbol('id');
const b = Symbol('id');

console.log(typeof a);        // -> 'symbol'
console.log(a === b);         // -> false
console.log(a.description);   // -> 'id'
console.log(String(a));       // -> 'Symbol(id)'
console.log(a === a);         // -> true, the only thing equal to a symbol is itself

The description is a label for your debugger, not an identity.

const key = Symbol('total');

console.log('the key is: ' + key);

+ calls ToPrimitive on the symbol, and Symbol.prototype[Symbol.toPrimitive] deliberately throws TypeError: Cannot convert a Symbol value to a string. Explicit conversion is fine: String(key) gives 'Symbol(total)'. The asymmetry is intentional, because silent stringification would defeat the point of a unique key.

Symbols as property keys

Objects accept exactly two kinds of key: strings and symbols. A number key is converted to a string. A symbol key is left alone, and it is skipped by every enumeration API that predates symbols, which is what makes it feel hidden.

const SIZE = Symbol('size');

const cart = { items: ['tea'], [SIZE]: 1 };

console.log(Object.keys(cart));                    // -> [ 'items' ]
console.log(JSON.stringify(cart));                 // -> {"items":["tea"]}
console.log(cart[SIZE]);                           // -> 1
console.log(Object.getOwnPropertySymbols(cart));   // -> [ Symbol(size) ]
console.log(Reflect.ownKeys(cart));                // -> [ 'items', Symbol(size) ]

const copy = { ...cart };
console.log(copy[SIZE]);                           // -> 1, spread does copy symbol keys

Hidden from the old APIs, visible to the new ones. Not private, just unguessable.

OperationSees string keysSees symbol keys
Object.keys / for...in / JSON.stringifyyesno
Object.getOwnPropertyNamesyesno
Object.getOwnPropertySymbolsnoyes
Reflect.ownKeysyesyes
spread and Object.assignyesyes (enumerable only)
structuredCloneyesno (symbol keys are dropped)
in and Reflect.hasyesyes

The global registry

Sometimes you want the same symbol in two places that cannot share a variable, for example a page and an iframe, or two copies of a library. Symbol.for(key) looks the symbol up in a process-wide registry and creates it only if it is missing.

console.log(Symbol.for('app.id') === Symbol.for('app.id'));  // -> true
console.log(Symbol('app.id') === Symbol('app.id'));          // -> false

console.log(Symbol.keyFor(Symbol.for('app.id')));            // -> 'app.id'
console.log(Symbol.keyFor(Symbol('app.id')));                // -> undefined

// Well-known symbols are neither: they are their own thing.
console.log(Symbol.keyFor(Symbol.iterator));                 // -> undefined

Registry symbols are shared by string key. Plain symbols never are.

Well-known symbols: the protocol hooks

The specification keeps a set of fixed symbols on the Symbol constructor. Each one names a place where the language asks your object a question. Implement the symbol and your object answers, which means for...of, spread, instanceof, + and String() all start working on types you wrote yourself.

SymbolThe language asksUsed by
Symbol.iteratorhow do I walk you?for...of, spread, destructuring, Array.from
Symbol.asyncIteratorhow do I await-walk you?for await...of
Symbol.toPrimitivewhat number or string are you?+, <, ==, String(), Number()
Symbol.toStringTagwhat should I call you?Object.prototype.toString
Symbol.hasInstanceis this value one of you?instanceof
Symbol.disposehow do I release you?using (lesson 15.6)
Symbol.asyncDisposehow do I await-release you?await using
Symbol.specieswhich constructor should derived results use?map, slice, filter
Symbol.unscopableswhich of your keys must with ignore?legacy with blocks
Symbol.match / replace / search / splitcan you act as a pattern?String.prototype methods
const range = {
  from: 1,
  to: 4,
  [Symbol.iterator]() {
    let n = this.from;
    const last = this.to;
    return { next: () => (n <= last ? { value: n++, done: false } : { value: undefined, done: true }) };
  },
};

console.log([...range]);            // -> [ 1, 2, 3, 4 ]
for (const n of range) console.log(n);
const [first, second] = range;
console.log(first, second);         // -> 1 2
console.log(Math.max(...range));    // -> 4

One method turns a plain object into something for...of and spread understand.

class Money {
  constructor(cents, currency = 'GBP') {
    this.cents = cents;
    this.currency = currency;
  }
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.cents;
    if (hint === 'string') return `${(this.cents / 100).toFixed(2)} ${this.currency}`;
    return `Money(${this.cents})`;   // 'default', used by + and ==
  }
}

const price = new Money(1250);
console.log(+price);              // -> 1250        (hint 'number')
console.log(`${price}`);          // -> 12.50 GBP   (hint 'string')
console.log(price + '');          // -> Money(1250) (hint 'default')
console.log(price > new Money(9)); // -> true, relational comparison uses hint 'number'

Symbol.toPrimitive receives a hint: "number", "string" or "default".

class Result {
  get [Symbol.toStringTag]() {
    return 'Result';
  }
}

console.log(Object.prototype.toString.call(new Result()));  // -> [object Result]
console.log(Object.prototype.toString.call([]));            // -> [object Array]
console.log(Object.prototype.toString.call(new Map()));     // -> [object Map]

// A hand-rolled type checker, the way lodash does it.
const typeTag = (v) => Object.prototype.toString.call(v).slice(8, -1);
console.log(typeTag(null), typeTag(/x/), typeTag(new Date(0)));  // -> Null RegExp Date

toStringTag fixes the type tag that logging and Object.prototype.toString report.

const Thenable = {
  [Symbol.hasInstance](value) {
    return value != null && typeof value.then === 'function';
  },
};

console.log(Promise.resolve(1) instanceof Thenable);   // -> true
console.log({ then() {} } instanceof Thenable);        // -> true
console.log(42 instanceof Thenable);                   // -> false

Symbol.hasInstance lets you redefine instanceof for duck typing.

Try it yourself

Metadata nobody can collide with

// Library A
const A_META = Symbol('libA.meta');
const tagA = (obj, info) => Object.assign(obj, { [A_META]: info });
const readA = (obj) => obj[A_META];

// Library B, written by someone who never met library A
const B_META = Symbol('libB.meta');
const tagB = (obj, info) => Object.assign(obj, { [B_META]: info });
const readB = (obj) => obj[B_META];

const user = { name: 'Ada' };
tagA(user, { source: 'api' });
tagB(user, { source: 'cache' });

console.log(readA(user));                        // -> { source: 'api' }
console.log(readB(user));                        // -> { source: 'cache' }
console.log(Object.keys(user));                  // -> [ 'name' ]
console.log(JSON.stringify(user));               // -> {"name":"Ada"}
console.log(Object.getOwnPropertySymbols(user).length); // -> 2

Add a second library that also wants to store a version. Give it its own symbol and prove both survive. Then try it with the string key 'meta' twice and watch one overwrite the other.

One object, three faces

class Duration {
  constructor(seconds) {
    this.seconds = seconds;
  }
  [Symbol.toPrimitive](hint) {
    console.log('hint:', hint);
    if (hint === 'number') return this.seconds;
    if (hint === 'string') return `${Math.floor(this.seconds / 60)}m ${this.seconds % 60}s`;
    return `Duration(${this.seconds}s)`;
  }
}

const d = new Duration(90);
console.log(+d);
console.log(`${d}`);
console.log(d + '');
console.log([new Duration(30), new Duration(10)].sort((a, b) => a - b).map(String));

Make Duration sortable by adding relational comparison, then check what duration == 90 does and explain the hint it used.

Exercises

A temperature that behaves like a primitive

Write a class Temperature taking celsius in the constructor. Implement Symbol.toPrimitive so that the number hint gives the celsius number, the string hint gives "21.0C" (one decimal place, then a capital C), and the default hint gives "Temperature(21)". Also expose a celsius property holding the raw number.

Collision-proof metadata

Write createTagger(name). It returns { tag, read, has }. tag(obj, value) stores value on obj under a symbol key private to that tagger and returns obj. read(obj) returns the stored value or undefined. has(obj) returns a boolean. Two taggers must never see each other's data, and tagged values must not appear in Object.keys or JSON.stringify.

Check yourself

What does this log?
1 2 — Object.keys reports enumerable string keys only, so it sees just b and returns length 1. Reflect.ownKeys is the one API that reports strings and symbols together, so it sees both and returns 2. If you need to copy an object faithfully, Reflect.ownKeys is the list you must walk.
Which statement about symbol-keyed properties is true?
They are skipped by JSON.stringify and Object.keys, but readable via Object.getOwnPropertySymbols — Symbol keys buy you collision safety, not privacy: Object.getOwnPropertySymbols hands them to anyone who asks. They are skipped by the string-key APIs. Spread and Object.assign do copy enumerable symbol keys, while structuredClone drops them, which is the opposite of the third option.
An object has valueOf returning 10 and toString returning "ten". What is `${obj}` and what is obj + 1?
'ten' and 11 — A template literal uses the string hint, which tries toString first, so you get 'ten'. + uses the default hint, which tries valueOf first, so you get the number 10 and then 10 + 1 is 11. Same object, two different primitives, decided entirely by the hint.
You want the same symbol to be shared between your page and an iframe. Which do you use?
Symbol.for('app.channel') in both places — Symbol.for reads from a registry shared across realms, so both sides get the identical symbol. Two Symbol('app.channel') calls produce two unequal symbols. Symbol.keyFor goes the other way: it takes a registry symbol and gives back its string key, and returns undefined for anything else.

Common mistakes

  • Expecting '' + symbol to work. Explicit String(symbol) is allowed, implicit conversion throws.
  • Treating symbol keys as private. Object.getOwnPropertySymbols reads them all.
  • Losing symbol-keyed data by round-tripping through JSON.stringify or structuredClone.
  • Using Symbol.for with a bare name like 'state', which puts you back in a global string namespace.
  • Defining Symbol.toStringTag as a data property when a getter is what you want on a class prototype.
  • Assuming Symbol.iterator must return a generator. It only has to return an object with a next method.

Takeaways

  • A symbol is a unique, never-equal primitive designed to be used as a property key.
  • Symbol keys are invisible to Object.keys, for...in and JSON.stringify, and visible to Reflect.ownKeys.
  • Unguessable is not private. Use #fields or a WeakMap when you need real privacy.
  • Symbol.for shares symbols by string key across realms. Namespace those keys.
  • Well-known symbols are the language asking your object a question. Answer them and built-in syntax starts working.
  • Symbols throw on implicit string conversion, deliberately, so keys cannot be forged by concatenation.