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 itselfThe 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 keysHidden from the old APIs, visible to the new ones. Not private, just unguessable.
| Operation | Sees string keys | Sees symbol keys |
|---|---|---|
Object.keys / for...in / JSON.stringify | yes | no |
Object.getOwnPropertyNames | yes | no |
Object.getOwnPropertySymbols | no | yes |
Reflect.ownKeys | yes | yes |
spread and Object.assign | yes | yes (enumerable only) |
structuredClone | yes | no (symbol keys are dropped) |
in and Reflect.has | yes | yes |
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)); // -> undefinedRegistry 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.
| Symbol | The language asks | Used by |
|---|---|---|
Symbol.iterator | how do I walk you? | for...of, spread, destructuring, Array.from |
Symbol.asyncIterator | how do I await-walk you? | for await...of |
Symbol.toPrimitive | what number or string are you? | +, <, ==, String(), Number() |
Symbol.toStringTag | what should I call you? | Object.prototype.toString |
Symbol.hasInstance | is this value one of you? | instanceof |
Symbol.dispose | how do I release you? | using (lesson 15.6) |
Symbol.asyncDispose | how do I await-release you? | await using |
Symbol.species | which constructor should derived results use? | map, slice, filter |
Symbol.unscopables | which of your keys must with ignore? | legacy with blocks |
Symbol.match / replace / search / split | can 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)); // -> 4One 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 DatetoStringTag 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); // -> falseSymbol.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.keysreports enumerable string keys only, so it sees justband returns length 1.Reflect.ownKeysis 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.ownKeysis the list you must walk.- Which statement about symbol-keyed properties is true?
- They are skipped by
JSON.stringifyandObject.keys, but readable viaObject.getOwnPropertySymbols— Symbol keys buy you collision safety, not privacy:Object.getOwnPropertySymbolshands them to anyone who asks. They are skipped by the string-key APIs. Spread andObject.assigndo copy enumerable symbol keys, whilestructuredClonedrops them, which is the opposite of the third option. - An object has
valueOfreturning10andtoStringreturning"ten". What is`${obj}`and what isobj + 1? 'ten'and11— A template literal uses thestringhint, which triestoStringfirst, so you get'ten'.+uses thedefaulthint, which triesvalueOffirst, so you get the number10and then10 + 1is11. 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.forreads from a registry shared across realms, so both sides get the identical symbol. TwoSymbol('app.channel')calls produce two unequal symbols.Symbol.keyForgoes the other way: it takes a registry symbol and gives back its string key, and returnsundefinedfor anything else.
Common mistakes
- Expecting
'' + symbolto work. ExplicitString(symbol)is allowed, implicit conversion throws. - Treating symbol keys as private.
Object.getOwnPropertySymbolsreads them all. - Losing symbol-keyed data by round-tripping through
JSON.stringifyorstructuredClone. - Using
Symbol.forwith a bare name like'state', which puts you back in a global string namespace. - Defining
Symbol.toStringTagas a data property when a getter is what you want on a class prototype. - Assuming
Symbol.iteratormust return a generator. It only has to return an object with anextmethod.
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...inandJSON.stringify, and visible toReflect.ownKeys. - Unguessable is not private. Use
#fieldsor aWeakMapwhen you need real privacy. Symbol.forshares 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.