JAVASCRIPT / MAPS, SETS, SYMBOLS, AND PROXIES
The global symbol registry and shared keys
Use Symbol.for and Symbol.keyFor to mint symbols that independent code can look up by string key, and know when a plain Symbol() is the safer choice.
What you will learn
- Symbol.for(key) returns the same symbol every time; Symbol(desc) never repeats
- Recover a registry key with Symbol.keyFor; unregistered symbols give undefined
- Namespace keys like 'acme.cache' because the registry is one flat string space
- Well-known symbols such as Symbol.iterator are not registry entries
Understanding The global symbol registry and shared keys
Symbol('tag') creates an identity with no name attached, and that is the whole point: nobody can guess it, so nobody can collide with it. The cost is that two pieces of code which never meet can never arrive at the same symbol. The global symbol registry inverts the tradeoff. Symbol.for('acme.tag') asks a program-wide table for the symbol filed under that string, creating and filing one on the first call and handing back that same symbol on every call afterwards, so the spelling of the key becomes the identity instead of a shared variable.
The mental model is a single flat Map from string to symbol that lives beside your program, outside every module scope, shared by all realms of one JavaScript agent, and append-only. Nothing is ever removed from it, so a registered symbol lives as long as the program does. Symbol.keyFor is the reverse lookup, and it answers only for symbols that are actually in that table: hand it Symbol('x') and you get undefined, and hand it Symbol.iterator and you also get undefined, which is the clearest proof that well-known symbols are their own category rather than pre-registered keys.
Choosing between the two forms is a question of who has to agree. Private per-module metadata wants Symbol(), because outsiders should not be able to name the key and an unreferenced symbol can be collected. A cross-boundary protocol wants Symbol.for: "any object carrying this method can render itself", or "this object was made by my library, possibly by a second copy of it loaded from a different bundle". Because one flat namespace is shared by everything on the page, prefix keys with something you own, which is why real libraries write Symbol.for('react.element') or Symbol.for('nodejs.util.inspect.custom') rather than Symbol.for('element').
const local1 = Symbol('app.version');
const local2 = Symbol('app.version');
const shared1 = Symbol.for('app.version');
const shared2 = Symbol.for('app.version');
console.log('local1 === local2:', local1 === local2);
console.log('shared1 === shared2:', shared1 === shared2);
console.log('local1 === shared1:', local1 === shared1);
console.log('keyFor(shared1):', Symbol.keyFor(shared1));
console.log('keyFor(local1):', Symbol.keyFor(local1));
console.log('shared1.description:', shared1.description);
console.log('keyFor(Symbol.iterator):', Symbol.keyFor(Symbol.iterator));Symbol.for trades away uniqueness for lookup-by-name, making a string key the stable identity of one shared symbol that any realm can rediscover.
Worked examples
A handshake between two unrelated functions
Two pieces of code that share no import agree on a property key by spelling the same registry string.
function makeRecord() {
const TAG = Symbol.for('acme.serialize');
return { id: 7, [TAG]: () => 'record#7' };
}
function render(obj) {
const TAG = Symbol.for('acme.serialize');
return typeof obj[TAG] === 'function' ? obj[TAG]() : String(obj);
}
console.log(render(makeRecord()));
console.log(render({ id: 8 }));Example explained
Line 1Both Symbol.for('acme.serialize') calls resolve to one symbol, so the key written inside makeRecord is the key read inside render.
Line 2Changing either call to Symbol('acme.serialize') breaks the handshake, because each such call mints a fresh key that the other side cannot name.
Line 3The plain object takes the fallback branch: obj[TAG] is undefined, so typeof is 'undefined' and String(obj) produces [object Object].
keyFor as the inverse of Symbol.for
Sending a symbol across a JSON boundary by shipping its registry key and rebuilding it on the other side.
const KIND = Symbol.for('shape.kind');
function encode(sym) {
const key = Symbol.keyFor(sym);
if (key === undefined) throw new TypeError('symbol is not in the registry');
return key;
}
const wire = JSON.stringify({ kind: encode(KIND) });
console.log(wire);
const back = Symbol.for(JSON.parse(wire).kind);
console.log(back === KIND);
try {
encode(Symbol('shape.kind'));
} catch (err) {
console.log(err.message);
}Example explained
Line 1Symbol.keyFor(KIND) returns the plain string 'shape.kind', the only part of a symbol that survives serialization.
Line 2Symbol.for on the parsed key finds the existing registry entry rather than making a new symbol, so back === KIND holds.
Line 3The unregistered symbol has no key at all, so keyFor returns undefined and encode rejects it; a private symbol simply cannot be described in text.
Why keys need a namespace
Two libraries that both choose the bare key 'id' silently write to the same property.
const fromLibA = Symbol.for('id');
const fromLibB = Symbol.for('id');
const user = {};
user[fromLibA] = 'A-1';
user[fromLibB] = 'B-1';
console.log(Object.getOwnPropertySymbols(user).length);
console.log(user[fromLibA]);
console.log(fromLibA === fromLibB, Symbol.keyFor(fromLibA));Example explained
Line 1Both constants hold the same registry symbol, so the second assignment overwrites the property created by the first.
Line 2Object.getOwnPropertySymbols reports one key instead of two, which is why the collision is easy to miss.
Line 3The registry has no scoping of its own, so a key like 'acme.id' is the only thing keeping two libraries apart.
Important notes
The key is coerced with ToString, so Symbol.for(1) and Symbol.for('1') return the same symbol, which is one more reason not to derive keys from data.
The registry spans realms of the same agent, such as a page plus its same-origin iframes or every module in one Node process, but it is not persisted, does not extend into a Worker, and symbols still cannot travel through JSON: send the key string and call Symbol.for again on the far side.
Common mistakes
Writing Symbol.for('Symbol.iterator') and expecting the real iteration hook: you get an ordinary registry symbol, the actual Symbol.iterator slot stays empty, and for...of still throws "is not iterable".
Using bare keys such as Symbol.for('id') or Symbol.for('meta'): another library eventually picks the same string, both write to one property, and the last writer wins with no error.
Calling Symbol.for(user.id) once per record: the registry is append-only, so every key and symbol is retained for the life of the program, and the leak never appears as unreachable memory the way Symbol() would.
Try it yourself
Change, predict, then run
In a browser console, run document.body.appendChild(document.createElement('iframe')), then log frames[0].Symbol.for('demo.tag') === Symbol.for('demo.tag') and frames[0].Symbol('demo.tag') === Symbol('demo.tag'). Explain in one line why the results differ even though both realms used the same text.
Open the JavaScript workspaceCheck your understanding
Two independently bundled copies of one library end up on the same page, and each tags the objects it creates so the other copy can recognize them. Which tagging scheme actually works across both copies?
- Symbol('acme.node') in each copy, because identical descriptions make the two symbols compare equal
- Symbol.for('acme.node') in one copy and Symbol('acme.node') in the other, because Symbol.keyFor maps both to the same key
- Symbol.for('acme.node') in both copies, because each call looks the string up in one registry shared by every realm of the agent and receives the identical symbol
- A module-level Symbol('acme.node') that both copies import, because bundling guarantees a single shared module instance
Show answer
Symbol.for is a lookup rather than a constructor: the first call files a symbol under the key and every later call anywhere in the agent returns that same value, which is exactly the duplicate-copy problem the registry exists to solve. The import-based answer is the tempting one because it works inside a single build, but the premise is two separate bundles, so each evaluates its own module and its own Symbol() call. The description-based answers fail on the same misconception: description is only a debugging label and never takes part in equality, so two Symbol() calls with identical text are two distinct keys, and keyFor returns undefined for the unregistered one.