Skip to content

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 id still holds A-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.keyFor returns the key of a registered symbol, and undefined for a symbol from Symbol() and for a well-known symbol such as Symbol.iterator, which is not in the registry.
  • Another realm gets the same symbol. A node:vm context has globals of its own, so its Symbol function is not this one, yet its Symbol.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 WeakMap refuses 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 from Symbol.for has none: it can be produced again from its key without holding any reference to it (CanBeHeldWeakly ↗). A symbol from Symbol() is accepted. WeakMap and WeakSet has 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) and audit.toString() give "Symbol(audit)". audit.description gives the description alone, and a symbol made with no description has undefined there.
  • 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: the TypeError stops 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 from Symbol(), a registered one, and one inside an object. Node's structuredClone is 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 id and 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.

checked by Ada
true

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) or sym.description, never with + or a template literal.
  • Do not use a symbol key for secrets or for data that has to travel: Reflect.ownKeys lists it, spread copies it, and JSON.stringify and structuredClone leave it behind.
  • In TypeScript, annotate a Symbol.for constant as symbol, or tsc refuses to compare it with another.

In other languages

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.

first reads: A
second reads: B
JSON: {"id":"A-1001"}
own keys: id, Symbol(cache), Symbol(cache)
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

See also