JavaScriptSymbols

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

JS
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 symbols
No `new` allowed
`new Symbol()` throws a TypeError. Symbols are primitives, not objects — call `Symbol()` without `new`, like `String()` or `Number()`.
Use 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.

JS
// 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 hidden

Symbol-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.

JS
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;                        // false
When to use Symbol.for
Use the registry only when you need cross-module identity (an extensible protocol, a tag every plugin should agree on). For the common "private-ish property key" case, plain `Symbol()` is the right tool — its uniqueness is the whole point.
Use 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 in for...of, spread (...obj), Array.from and destructuring.

  • Symbol.asyncIterator — the async equivalent, used by for await...of.

  • Symbol.toPrimitive — controls how an object is coerced to a primitive (+obj, `${obj}``, etc.).

  • Symbol.toStringTag — customises the result of Object.prototype.toString.call(obj), which is what {}.toString.call(...) returns.

  • Symbol.hasInstance — customises instanceof checks.

  • Symbol.isConcatSpreadable — whether Array.prototype.concat should flatten this value.

Making your own iterable

JS
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:

JS
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

JS
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 concat
Symbols are not enumerated by default

JS
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)]
Hidden, not secret
Symbol keys are *not* a security boundary. Any code can list them with `getOwnPropertySymbols`. They simply stay out of the way of casual iteration and serialisation.
Real-world places you've already used symbols
  • Every for...of loop calls obj[Symbol.iterator]() under the hood — arrays, strings, Maps, Sets, the DOM NodeList, and arguments all implement it.

  • Promise rejections 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 in console.log and util.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.