Symbols
A Symbol is a primitive value that is guaranteed to be unique — every Symbol() call produces a distinct value that is never equal to any other. They look strange at first ("a value whose only purpose is to be unique?") but they solve two specific problems elegantly: collision-free property keys and language-extension hooks like Symbol.iterator. Most application code doesn't create symbols directly, but every JavaScript developer benefits from the ones the language already uses.
The basic idea
const a = Symbol();
const b = Symbol();
a === b; // false — every Symbol() is fresh
typeof a; // "symbol"
// Optional description — for debugging only, NOT identity
const tag = Symbol("user-id");
tag.description; // "user-id"
tag.toString(); // "Symbol(user-id)"
const tag2 = Symbol("user-id");
tag === tag2; // false — same description, different symbolsUse case 1 — collision-free property keys
The classic problem: you want to attach metadata to an object you didn't create, and you can't risk overwriting a property someone else might add. A symbol is guaranteed not to clash.
// Library A
const ID = Symbol("id");
function tagWithId(obj, id) {
obj[ID] = id;
}
// Library B, completely unaware of A
function tagWithId2(obj, id) {
obj["id"] = id; // string key — could clash with user data
}
const user = { name: "Ada", id: "set-by-user" };
tagWithId(user, 42);
user.id; // "set-by-user" — untouched
user[ID]; // 42 — library A's tag
Object.keys(user); // ["name", "id"] — the symbol is hiddenSymbol-keyed properties don't show up in Object.keys, for...in or JSON.stringify. They are still reachable via Object.getOwnPropertySymbols(obj) or Reflect.ownKeys(obj) — they're hidden by default, not actually private.
Symbol.for and Symbol.keyFor — the global registry
Sometimes you want the same symbol across modules — for example a framework that lets plugins recognise each other. Symbol.for(key) looks up (or creates) a symbol in a process-wide registry keyed by string.
const a = Symbol.for("app.user");
const b = Symbol.for("app.user");
a === b; // true — both come from the registry
Symbol.keyFor(a); // "app.user"
// Compare with the plain constructor:
const c = Symbol("app.user");
Symbol.keyFor(c); // undefined — not in the registry
a === c; // falseUse case 2 — well-known symbols
The biggest reason symbols exist in the language is to give built-in protocols a name that cannot collide with user property names. These are called well-known symbols. The most important ones:
Symbol.iterator— defines how an object behaves infor...of, spread (...obj),Array.fromand destructuring.Symbol.asyncIterator— the async equivalent, used byfor await...of.Symbol.toPrimitive— controls how an object is coerced to a primitive (+obj, `${obj}``, etc.).Symbol.toStringTag— customises the result ofObject.prototype.toString.call(obj), which is what{}.toString.call(...)returns.Symbol.hasInstance— customisesinstanceofchecks.Symbol.isConcatSpreadable— whetherArray.prototype.concatshould flatten this value.
Making your own iterable
class Range {
constructor(start, end) { this.start = start; this.end = end; }
[Symbol.iterator]() {
let i = this.start;
const end = this.end;
return {
next() {
return i < end
? { value: i++, done: false }
: { value: undefined, done: true };
},
};
}
}
const r = new Range(1, 4);
for (const n of r) console.log(n); // 1, 2, 3
console.log([...r]); // [1, 2, 3]1 2 3 [1, 2, 3]
The same trick works with a generator function, which is the modern way to write iterables:
class Range {
constructor(start, end) { this.start = start; this.end = end; }
*[Symbol.iterator]() {
for (let i = this.start; i < this.end; i++) yield i;
}
}Symbol.toPrimitive — controlling coercion
const money = {
value: 42,
currency: "USD",
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.value;
if (hint === "string") return `${this.value} ${this.currency}`;
return `${this.value} ${this.currency}`; // "default" hint
},
};
+money; // 42 — number hint
`I owe you ${money}`; // "I owe you 42 USD" — string hint
money + 1; // "42 USD1" — default hint, then string concatSymbols are not enumerated by default
const tag = Symbol("internal");
const obj = { name: "Ada", [tag]: 42 };
Object.keys(obj); // ["name"]
JSON.stringify(obj); // '{"name":"Ada"}'
for (const k in obj) console.log(k); // "name"
// But you can still discover them on purpose:
Object.getOwnPropertySymbols(obj); // [Symbol(internal)]
Reflect.ownKeys(obj); // ["name", Symbol(internal)]Real-world places you've already used symbols
Every
for...ofloop callsobj[Symbol.iterator]()under the hood — arrays, strings, Maps, Sets, the DOMNodeList, andargumentsall implement it.Promiserejections wrap values and inspect them via well-known symbol protocols.React uses an internal symbol (
Symbol.for("react.element")) on every JSX element so it can recognise elements from other React versions.Node's
util.inspect.custom(a symbol) lets you control how an object prints inconsole.logandutil.inspect.Many DI containers and frameworks (Angular, NestJS, InversifyJS) use symbols as injection tokens because they can never collide with string keys.
You can write JavaScript for years without typing Symbol once — the language uses them on your behalf. But the moment you build a library, design a plugin interface, or want truly private-ish properties on a shared object, symbols are the cleanest tool the language gives you.