JAVASCRIPT / MAPS, SETS, SYMBOLS, AND PROXIES
Symbol keys and well-known symbols
Attach non-colliding metadata with Symbol() keys and hook into for...of, coercion and instanceof using well-known symbols.
What you will learn
- Create a symbol once with Symbol('label'); the label is debug text, not identity.
- Park metadata on symbol keys that Object.keys, for...in and JSON.stringify skip.
- Implement Symbol.iterator so for...of and spread work on your own object.
- Use Symbol.toPrimitive and Symbol.toStringTag to steer coercion and toString.
Understanding Symbol keys and well-known symbols
Symbol() returns a brand new primitive on every call, and the only meaningful trait of that value is its own identity: Symbol('id') === Symbol('id') is false, and the text in the parentheses is a description used by devtools, String(sym) and sym.description, nothing more. What makes symbols useful as keys is that an object keeps symbol-keyed properties in a list separate from its string keys, so obj[Symbol('id')] can never overwrite obj.id or a symbol some other library created. Reading the value back requires holding the exact symbol, which turns "do not collide with other code" from a naming convention into a guarantee.
Because that list is separate, symbol keys drop out of all the string-key machinery: Object.keys, for...in and JSON.stringify enumerate string keys only, so bookkeeping stored on a symbol never shows up in serialized output or in a loop over an object's fields. They are not private, though. Object.getOwnPropertySymbols and Reflect.ownKeys hand them out, and Object.assign and object spread copy them into clones, so treat symbol keys as collision-proof and low-noise rather than as access control.
The Symbol function also carries a fixed set of symbol values that the specification itself uses as lookup keys: Symbol.iterator, Symbol.asyncIterator, Symbol.toPrimitive, Symbol.toStringTag, Symbol.hasInstance and a few more. for...of does not search your object for a method named next; it evaluates obj[Symbol.iterator] and works with whatever that returns, and +obj checks obj[Symbol.toPrimitive] before falling back to valueOf and toString. The mental model is that each language protocol is a property lookup under a key nobody can accidentally pick, so implementing a built-in behaviour is just defining that key as a method.
const CACHE = Symbol('cache');
const config = {
name: 'reader',
[CACHE]: { hits: 0 },
[Symbol.toStringTag]: 'Config'
};
config[CACHE].hits += 1;
console.log(Object.keys(config).join(', '));
console.log(JSON.stringify(config));
console.log(Object.prototype.toString.call(config));
console.log(config[CACHE].hits);
console.log(String(Object.getOwnPropertySymbols(config)[0]));
console.log(Symbol('cache') === CACHE);Symbol-keyed properties live in a namespace that cannot collide with string keys, and the language reads a fixed set of those keys, the well-known symbols, to decide how your object iterates, coerces and describes itself.
Worked examples
Symbol.iterator makes an object work with for...of
Defining the well-known iterator key turns a plain object into something spread and for...of accept.
const range = {
from: 3,
to: 6,
[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].join(' '));
for (const n of range) console.log(n);
console.log(typeof range[Symbol.iterator]);Example explained
Line 1for...of never looks for a next method on range itself; it calls range[Symbol.iterator]() and drives the object that comes back.
Line 2n is declared inside the method, so each call to the iterator starts a fresh count and range can be consumed twice.
Line 3Spread uses exactly the same protocol, which is why [...range] needs no extra code.
Line 4A method literally named iterator, or a 'Symbol.iterator' string key, would be ignored: only the symbol value stored on Symbol counts.
Symbol.toPrimitive and conversion hints
One method decides what the object becomes as a number, inside a template, and in an ambiguous + expression.
const price = {
amount: 1250,
currency: 'EUR',
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount;
return (this.amount / 100).toFixed(2) + ' ' + this.currency;
},
valueOf() {
return 0;
}
};
console.log(+price);
console.log(`${price}`);
console.log(price + '');Example explained
Line 1Unary + passes the hint 'number', so the first branch runs and 1250 is returned as a number, not a parsed string.
Line 2Template interpolation passes 'string', producing the formatted amount from the second branch.
Line 3price + '' passes 'default' because + is ambiguous between addition and concatenation, and 'default' falls through to the string branch here.
Line 4valueOf is never consulted: when Symbol.toPrimitive exists it fully replaces the valueOf/toString fallback chain, which is why the 0 is unreachable.
Same description, different keys
Two symbols made from the same text are distinct property keys, and spread carries both into a clone.
const idA = Symbol('id');
const idB = Symbol('id');
const user = { name: 'ada', id: 'string-key' };
user[idA] = 'lib-a-42';
user[idB] = 'lib-b-99';
console.log(Object.getOwnPropertySymbols(user).length);
console.log(user[idA] + ' / ' + user[idB] + ' / ' + user.id);
console.log(idA.description === idB.description);
const copy = { ...user };
console.log(copy[idB]);
console.log(Object.keys(copy).join(','));Example explained
Line 1idA and idB share the description 'id' yet compare as different keys, so both values sit on user at once.
Line 2The string key 'id' occupies a third, unrelated slot; no assignment overwrites another in either direction.
Line 3Matching descriptions prove the label is only metadata: it is printed by String(sym) and read by sym.description, never used for lookup.
Line 4Object spread copies own enumerable symbol keys, so copy[idB] survives while Object.keys(copy) still lists only the string keys.
Important notes
Symbols never coerce to strings implicitly, so 'key: ' + sym throws a TypeError; use String(sym) or sym.description when logging.
JSON.stringify silently drops symbol-keyed properties and returns undefined for a symbol passed directly, so anything that must survive a JSON round trip has to live on a string key.
Common mistakes
Writing obj['Symbol.iterator'] = fn, or a method named iterator, instead of the computed key [Symbol.iterator]; that creates an ordinary string property and for...of still throws "obj is not iterable".
Calling Symbol('id') a second time in another function or module and expecting it to match the first key: the descriptions are equal but the symbols are not, so the lookup quietly returns undefined.
Treating a symbol key as private and stashing a token or password there; Object.getOwnPropertySymbols reveals it and spread or Object.assign copies it into every clone.
Try it yourself
Change, predict, then run
In a browser console, build an object deck whose card list lives under const CARDS = Symbol('cards') and give it a [Symbol.iterator] method that walks that array. Confirm that [...deck] lists the cards while Object.keys(deck) is empty and JSON.stringify(deck) prints {}.
Open the JavaScript workspaceCheck your understanding
A library stores internal state on your object under const KEY = Symbol('state') and never exports KEY. What is actually true about that property?
- It is genuinely private; no code outside the library can reach it.
- Any module that calls Symbol('state') gets the same key and can read the value.
- Outside code can obtain the key from Object.getOwnPropertySymbols and read the value, but Object.keys and JSON.stringify ignore it.
- Object spread drops the property, so clones silently lose the state.
Show answer
Symbol keys are collision-proof, not hidden: Object.getOwnPropertySymbols and Reflect.ownKeys return them, so anyone holding the object can recover the symbol and index with it, while string-key enumeration such as Object.keys, for...in and JSON.stringify skips it. Option 1 is tempting because both calls use the description 'state', but a description is only a label and every Symbol() call produces a fresh identity; sharing a key across modules requires the separate global registry, not a matching description. Option 3 is wrong because spread and Object.assign do copy own enumerable symbol keys.