Symbols — unique keys, unless you ask the registry for a shared one¶
Level: 201 · for anyone who has used a string key and worried about clashes
One line: Symbol("id") === Symbol("id") is false, so two modules' symbol keys never collide, while Symbol.for("id") hands every caller one shared symbol and brings the collision back; Object.keys, for...in, JSON.stringify and structuredClone skip a symbol key, but spread and Object.assign copy it and Reflect.ownKeys lists it — hidden from accidents, not from anyone looking.
Keywords: Symbol, Symbol.for, Symbol.keyFor, getOwnPropertySymbols, description
Two modules, billing and audit, each want to put an id of their own on the same order. Each makes its own symbol with the description "id" and writes through it. The last line makes the same two writes with the string key "id".
symbols_unique_js.js in full — pasted here by tools/run_examples.py from the file CI runs.
// Two modules each make a symbol described "id" and use it as a key on the
// same order. The descriptions are equal, the symbols are not, so neither
// write replaces the other. The last line makes the same two writes with the
// string key "id".
const row = (code, value) => console.log(`${code.padEnd(47)}${value}`);
const billingId = Symbol("id"); // made by the billing module
const auditId = Symbol("id"); // made by the audit module
row("billingId.description === auditId.description", billingId.description === auditId.description);
row("billingId === auditId", billingId === auditId);
row("billingId === billingId", billingId === billingId);
const order = { id: "A-1001" };
order[billingId] = "INV-7";
order[auditId] = "checked by Ada";
row("order.id", order.id);
row("order[billingId]", order[billingId]);
row("order[auditId]", order[auditId]);
const sameWritesWithAString = { id: "A-1001" };
sameWritesWithAString["id"] = "INV-7";
sameWritesWithAString["id"] = "checked by Ada";
row('the same writes with the string key "id"', JSON.stringify(sameWritesWithAString));
Verified output of symbols_unique_js.js — regenerated by tools/run_examples.py, never hand-typed.
billingId.description === auditId.description true
billingId === auditId false
billingId === billingId true
order.id A-1001
order[billingId] INV-7
order[auditId] checked by Ada
the same writes with the string key "id" {"id":"checked by Ada"}
Reading the output¶
- The descriptions are equal and the symbols are not. Each call to
Symbol()makes a new symbol, and===on two symbols asks whether they are one symbol; the descriptions play no part. ECMA-262 defines every symbol as unique, with a description that never changes (The Symbol Type ↗). - A symbol equals itself, so a module that keeps its symbol in a variable can always find its property again.
- Three properties now answer to "id". The order's own string key
idstill holdsA-1001, and each symbol key holds what its own module wrote. - The same writes with a string key land on one property. The audit module's write replaced the billing module's, and the billing module's had already replaced the order's own
A-1001.
The description is a label for people reading output, which String(sym) shows (below). Nothing is looked up by it.
Symbol.for asks a registry, and gets a shared symbol¶
Symbol.for(key) looks key up in the global symbol registry and makes a new symbol only when the key is not there yet (ECMA-262 — Symbol.for ↗). The program has both modules ask for "id", asks Symbol.keyFor about three symbols, asks for "id" again from a second realm, and offers two symbols to a WeakMap.
The program
symbols_registry_js.js in full — pasted here by tools/run_examples.py from the file CI runs.
// Symbol.for looks its key up in one registry and makes the symbol only the
// first time it is asked. Two modules that ask for "id" get the same symbol,
// so their writes collide, just as string keys do.
import vm from "node:vm";
const row = (code, value) => console.log(`${code.padEnd(48)}${String(value)}`);
row('Symbol.for("id") === Symbol.for("id")', Symbol.for("id") === Symbol.for("id"));
row('Symbol.for("id") === Symbol("id")', Symbol.for("id") === Symbol("id"));
const order = { id: "A-1001" };
order[Symbol.for("id")] = "INV-7"; // the billing module
order[Symbol.for("id")] = "checked by Ada"; // the audit module
row("Object.getOwnPropertySymbols(order).length", Object.getOwnPropertySymbols(order).length);
row('order[Symbol.for("id")]', order[Symbol.for("id")]);
// Symbol.keyFor answers with the registry key, or undefined.
row('Symbol.keyFor(Symbol.for("id"))', Symbol.keyFor(Symbol.for("id")));
row('Symbol.keyFor(Symbol("id"))', Symbol.keyFor(Symbol("id")));
row("Symbol.keyFor(Symbol.iterator)", Symbol.keyFor(Symbol.iterator));
// A node:vm context is a second realm, with a Symbol function of its own.
const realm = vm.createContext({});
const theirSymbol = vm.runInContext("Symbol", realm);
const theirId = vm.runInContext('Symbol.for("id")', realm);
row("theirSymbol === Symbol", theirSymbol === Symbol);
row('theirId === Symbol.for("id")', theirId === Symbol.for("id"));
// A WeakMap takes a symbol as a key only if it is not registered.
for (const [code, key] of [['Symbol("id")', Symbol("id")], ['Symbol.for("id")', Symbol.for("id")]]) {
let result = "accepted";
try {
new WeakMap().set(key, "cached");
} catch (error) {
result = `${error.name}: ${error.message}`;
}
row(`new WeakMap().set(${code}, "cached")`, result);
}
Verified output of symbols_registry_js.js — regenerated by tools/run_examples.py, never hand-typed.
Symbol.for("id") === Symbol.for("id") true
Symbol.for("id") === Symbol("id") false
Object.getOwnPropertySymbols(order).length 1
order[Symbol.for("id")] checked by Ada
Symbol.keyFor(Symbol.for("id")) id
Symbol.keyFor(Symbol("id")) undefined
Symbol.keyFor(Symbol.iterator) undefined
theirSymbol === Symbol false
theirId === Symbol.for("id") true
new WeakMap().set(Symbol("id"), "cached") accepted
new WeakMap().set(Symbol.for("id"), "cached") TypeError: Invalid value used as weak map key
- Two calls with one key give one symbol, and never the symbol that
Symbol("id")makes, even though its description is the same. - The collision is back. Both modules asked for
"id", got the same symbol and wrote to one property: the order has a single symbol key, holding the audit module's value. A registry key is a name every caller shares, as a string key is. Symbol.keyForreturns the key of a registered symbol, andundefinedfor a symbol fromSymbol()and for a well-known symbol such asSymbol.iterator, which is not in the registry.- Another realm gets the same symbol. A
node:vmcontext has globals of its own, so itsSymbolfunction is not this one, yet itsSymbol.for("id")is the very symbol this module got. ECMA-262 keeps the registry on the agent rather than on a realm, and describes a registered symbol as one that can be "retrieved from any realm" (GlobalSymbolRegistry Records ↗). - A
WeakMaprefuses a registered symbol as a key. ECMA-262 lets only a value with an identity of its own be held weakly, and notes that a symbol fromSymbol.forhas none: it can be produced again from its key without holding any reference to it (CanBeHeldWeakly ↗). A symbol fromSymbol()is accepted.WeakMapandWeakSethas the rest of the rule.
Which ways of listing keys see a symbol key?¶
The order has one string key and one symbol key. The program passes it to the six ways of listing or copying keys, then to Object.getOwnPropertySymbols, the one made for symbols. For a copy, the row shows the copy's own keys.
symbols_listing_keys_js.js in full — pasted here by tools/run_examples.py from the file CI runs.
// One order with a string key and a symbol key, handed to six ways of listing
// or copying keys (and the one built for symbols). Each row prints what came
// out; String() writes a symbol key as Symbol(audit).
const audit = Symbol("audit");
const order = { id: "A-1001", [audit]: "checked by Ada" };
const keys = (list) => list.map(String).join(", ");
const forIn = [];
for (const key in order) forIn.push(key);
const rows = [
["Object.keys(order)", keys(Object.keys(order))],
["for (const key in order)", keys(forIn)],
["JSON.stringify(order)", JSON.stringify(order)],
["keys of Object.assign({}, order)", keys(Reflect.ownKeys(Object.assign({}, order)))],
["keys of { ...order }", keys(Reflect.ownKeys({ ...order }))],
["Reflect.ownKeys(order)", keys(Reflect.ownKeys(order))],
["Object.getOwnPropertySymbols(order)", keys(Object.getOwnPropertySymbols(order))],
];
for (const [code, result] of rows) console.log(`${code.padEnd(37)}${result}`);
Verified output of symbols_listing_keys_js.js — regenerated by tools/run_examples.py, never hand-typed.
Object.keys(order) id
for (const key in order) id
JSON.stringify(order) {"id":"A-1001"}
keys of Object.assign({}, order) id, Symbol(audit)
keys of { ...order } id, Symbol(audit)
Reflect.ownKeys(order) id, Symbol(audit)
Object.getOwnPropertySymbols(order) Symbol(audit)
Object.keys, for...in and JSON.stringify see only the string key, so the audit value stays out of any code that loops over an order's keys or writes the order as JSON. Copying is different: Object.assign and spread take the symbol-keyed property along with the rest (MDN — Object.assign() ↗), and Reflect.ownKeys and Object.getOwnPropertySymbols list it for anyone who asks. A symbol key keeps a property out of the way; it does not keep it secret. For a property that no code outside its class can reach, see Private fields.
Why "" + sym throws when String(sym) works¶
The program turns one symbol into text seven ways, then compares what two different symbols become.
symbols_to_string_js.js in full — pasted here by tools/run_examples.py from the file CI runs.
// Seven ways of turning a symbol into text. A string result prints in quotes;
// a thrown TypeError prints as name: message.
const audit = Symbol("audit");
const attempts = [
["String(audit)", () => String(audit)],
["audit.toString()", () => audit.toString()],
["audit.description", () => audit.description],
["Symbol().description", () => Symbol().description],
["`${audit}`", () => `${audit}`],
['"" + audit', () => "" + audit],
["+audit", () => +audit],
];
for (const [code, attempt] of attempts) {
let result;
try {
const value = attempt();
result = typeof value === "string" ? JSON.stringify(value) : String(value);
} catch (error) {
result = `${error.name}: ${error.message}`;
}
console.log(`${code.padEnd(33)}${result}`);
}
// What a silent conversion would cost: two different symbols make one string,
// so keys built from those strings collide.
const other = Symbol("audit");
const byString = { [String(audit)]: "billing", [String(other)]: "audit" };
const bySymbol = { [audit]: "billing", [other]: "audit" };
console.log(`${"audit === other".padEnd(33)}${audit === other}`);
console.log(`${"String(audit) === String(other)".padEnd(33)}${String(audit) === String(other)}`);
console.log(`${"keys when converted by String()".padEnd(33)}${Reflect.ownKeys(byString).length}`);
console.log(`${"keys when kept as symbols".padEnd(33)}${Reflect.ownKeys(bySymbol).length}`);
Verified output of symbols_to_string_js.js — regenerated by tools/run_examples.py, never hand-typed.
String(audit) "Symbol(audit)"
audit.toString() "Symbol(audit)"
audit.description "audit"
Symbol().description undefined
`${audit}` TypeError: Cannot convert a Symbol value to a string
"" + audit TypeError: Cannot convert a Symbol value to a string
+audit TypeError: Cannot convert a Symbol value to a number
audit === other false
String(audit) === String(other) true
keys when converted by String() 1
keys when kept as symbols 2
- The first four rows work.
String(audit)andaudit.toString()give"Symbol(audit)".audit.descriptiongives the description alone, and a symbol made with no description hasundefinedthere. - The next three throw a
TypeError. A template literal and+with a string both convert with ECMA-262's ToString, whose rule for a symbol is to throw (ToString ↗, template literals ↗). Unary+converts with ToNumber, which throws for a symbol too (ToNumber ↗).String(value)is written to handle symbols: called as a function, it checks for a symbol before converting and returns"Symbol(", the description and")"(String ( value ) ↗). - The last four lines show what the refusal protects. Two different symbols become the same string,
"Symbol(audit)", so an object keyed by those strings has one key where the symbols kept two. MDN gives this as the reason: theTypeErrorstops a symbol from silently becoming a new string property name (MDN — Symbol type conversions ↗).
structuredClone refuses a symbol and drops a symbol key¶
The program clones a symbol, a registered symbol, an object holding a symbol, and an object with a symbol key.
symbols_structured_clone_js.js in full — pasted here by tools/run_examples.py from the file CI runs.
// structuredClone on a symbol, on a registered symbol, on an object holding a
// symbol, and on an object with a symbol key. A thrown error prints as
// name: message; a clone that succeeds prints its own keys.
const audit = Symbol("audit");
const order = { id: "A-1001", [audit]: "checked by Ada" };
const keys = (object) => Reflect.ownKeys(object).map(String).join(", ");
const attempts = [
['structuredClone(Symbol("audit"))', () => String(structuredClone(audit))],
['structuredClone(Symbol.for("audit"))', () => String(structuredClone(Symbol.for("audit")))],
["structuredClone({ checkedBy: audit })", () => JSON.stringify(structuredClone({ checkedBy: audit }))],
["keys of order", () => keys(order)],
["keys of structuredClone(order)", () => keys(structuredClone(order))],
];
for (const [code, attempt] of attempts) {
let result;
try {
result = attempt();
} catch (error) {
result = `${error.name}: ${error.message}`;
}
console.log(`${code.padEnd(39)}${result}`);
}
Verified output of symbols_structured_clone_js.js — regenerated by tools/run_examples.py, never hand-typed.
structuredClone(Symbol("audit")) DataCloneError: Symbol(audit) could not be cloned.
structuredClone(Symbol.for("audit")) DataCloneError: Symbol(audit) could not be cloned.
structuredClone({ checkedBy: audit }) DataCloneError: Symbol(audit) could not be cloned.
keys of order id, Symbol(audit)
keys of structuredClone(order) id
- Every symbol value throws a
DataCloneError: one fromSymbol(), a registered one, and one inside an object. Node'sstructuredCloneis the WHATWG method (Node 24 docs ↗), and the HTML standard's serialization steps throw for any symbol, with no exception for registered ones (HTML — StructuredSerializeInternal ↗). - A symbol key is dropped without a word. The same steps copy an object's properties from EnumerableOwnProperties, which lists string keys only (ECMA-262 — EnumerableOwnProperties ↗), so the clone has
idand nothing else.
The standard does not give a reason. The first program suggests one: a copy of a symbol would be a new symbol, and a new symbol never equals the old one, so a cloned key could not find anything the original key finds. Copying objects and postMessage cover the rest of what a clone keeps.
In TypeScript, every symbol constant has a type of its own¶
tsc gives a const initialized by Symbol() or by Symbol.for() a type of its own, a unique symbol, written typeof billingId. It refuses to compare two such types, because it treats each as a separate identity (TypeScript handbook — Symbols ↗):
symbols_unique_symbol_tserror.ts in full — pasted here by tools/run_examples.py from the file CI runs.
// tsc gives each const made by Symbol() or Symbol.for() a type of its own, a
// `unique symbol`, and refuses to compare two of them. For Symbol() that is
// right: the comparison is always false. For Symbol.for() it is wrong: the
// registry hands back one symbol, and the comparison is true.
const billingId = Symbol("id");
const auditId = Symbol("id");
console.log(billingId === auditId);
const registeredA = Symbol.for("id");
const registeredB = Symbol.for("id");
console.log(registeredA === registeredB);
What tsc says about symbols_unique_symbol_tserror.ts — regenerated by tools/run_examples.py, never hand-typed.
symbols_unique_symbol_tserror.ts:7:13 - error TS2367: This comparison appears to be unintentional because the types 'typeof billingId' and 'typeof auditId' have no overlap.
7 console.log(billingId === auditId);
~~~~~~~~~~~~~~~~~~~~~
symbols_unique_symbol_tserror.ts:11:13 - error TS2367: This comparison appears to be unintentional because the types 'typeof registeredA' and 'typeof registeredB' have no overlap.
11 console.log(registeredA === registeredB);
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Found 2 errors in the same file, starting at: symbols_unique_symbol_tserror.ts:7
Both comparisons get TS2367. The first refusal is right: the first program printed false for billingId === auditId. The second is wrong: the registry program printed true for two Symbol.for("id") calls, and tsc will not compile the comparison. The type has a use, though: it lets an interface name a symbol as a key. And a registered symbol annotated as plain symbol compares like any other symbol:
symbols_typed_keys_ts.ts in full — pasted here by tools/run_examples.py from the file CI runs.
// What a `unique symbol` type is for: a symbol key that tsc can check. The
// const `audit` has a type of its own, so an interface can name it as a key.
const audit = Symbol("audit");
interface Order {
id: string;
[audit]?: string;
}
const order: Order = { id: "A-1001" };
order[audit] = "checked by Ada";
console.log(order[audit]);
// A registered symbol annotated as plain `symbol`: tsc lets the comparison
// through, and Node prints what the registry program printed.
const registeredA: symbol = Symbol.for("id");
const registeredB: symbol = Symbol.for("id");
console.log(registeredA === registeredB);
Verified output of symbols_typed_keys_ts.ts — regenerated by tools/run_examples.py, never hand-typed.
order[audit] type-checks because Order names [audit] as a key, and Node prints the value. The two annotated registry symbols compare true, as they did in JavaScript. Branded types uses unique symbol keys to keep two kinds of ID apart.
What to do¶
- Make a key with
Symbol("description")when it must never clash with another module's key. Two symbols with one description are still two keys. - Keep the symbol in a variable (or export it): only code that holds it can read the property by name.
- Use
Symbol.for("app.orderId")only when separate code, even in another realm, must share one symbol, and give the key a prefix of your own: every caller with the same string gets the same symbol. - Print a symbol with
String(sym)orsym.description, never with+or a template literal. - Do not use a symbol key for secrets or for data that has to travel:
Reflect.ownKeyslists it, spread copies it, andJSON.stringifyandstructuredCloneleave it behind. - In TypeScript, annotate a
Symbol.forconstant assymbol, or tsc refuses to compare it with another.
In other languages¶
- Ruby: A symbol is a name, not a string ↗ — Ruby keeps exactly one symbol per spelling, so every Ruby symbol behaves like
Symbol.for, and nothing there makes a second:idthe waySymbol("id")does. - Ruby: A string key and a symbol key are two keys ↗ — in Ruby too a string key and a symbol key spelled alike are two keys, as
order.idandorder[billingId]are in the first program.
Practice¶
Kata: two caches on one order¶
Write makeTagger(name). It returns an object with two methods, tag(order, value) and read(order), which store and read a value on an order under a symbol key that belongs to that tagger alone. Make two taggers, both named "cache", tag { id: "A-1001" } with "A" through the first and "B" through the second, then print what each tagger reads, the order as JSON, and the order's own keys, so that the program prints exactly this:
Verified output of symbols_kata_js.js — regenerated by tools/run_examples.py, never hand-typed.
Solution
symbols_kata_js.js in full — pasted here by tools/run_examples.py from the file CI runs.
// Kata solution: two taggers that both describe their key as "cache" never
// overwrite each other, because each one makes its own symbol.
function makeTagger(name) {
const key = Symbol(name);
return {
tag(order, value) {
order[key] = value;
},
read(order) {
return order[key];
},
};
}
const first = makeTagger("cache");
const second = makeTagger("cache");
const order = { id: "A-1001" };
first.tag(order, "A");
second.tag(order, "B");
console.log(`first reads: ${first.read(order)}`);
console.log(`second reads: ${second.read(order)}`);
console.log(`JSON: ${JSON.stringify(order)}`);
console.log(`own keys: ${Reflect.ownKeys(order).map(String).join(", ")}`);
Sources¶
- MDN — Symbol ↗,
Symbol.for()↗,Symbol.keyFor()↗ andSymbol.prototype.description↗ - MDN —
Object.assign()↗ - ECMA-262 — The Symbol Type ↗
- ECMA-262 — Symbol Objects ↗
- ECMA-262 — ToString ↗, ToNumber ↗, String ( value ) ↗, EnumerableOwnProperties ↗ and CanBeHeldWeakly ↗
- HTML Standard — StructuredSerializeInternal ↗
- MDN — The structured clone algorithm ↗
- Node 24 docs —
structuredClone↗ andvm.createContext↗ - TypeScript handbook — Symbols ↗
See also¶
- Well-known symbols — the symbols the language itself uses as hooks
- Property keys — strings and symbols are the only key types
- JSON — what
JSON.stringifydrops, symbol keys included - Eight types — symbol as one of the seven primitives
- Converting on purpose —
String(sym)among the other explicit conversions - Copying objects — spread,
Object.assignandstructuredCloneon everything else WeakMapandWeakSet— which values can be weak keys- Private fields — a property that no code outside its class can list
- Branded types —
unique symbolkeys that keep two types apart - BigInt — the chapter's other newer primitive
- Errors around values and types — the messages this chapter's mistakes produce, each with its fix
- Lints around values and types — the checks that catch those mistakes before the code runs