JavaScript Symbols Explained: The Identifier Type Almost No One Uses

Link copied
JavaScript Symbols Explained: The Identifier Type Almost No One Uses

JavaScript Symbols Explained: The Identifier Type Almost No One Uses

Symbols are JavaScript's seventh primitive type — alongside number, string, boolean, undefined, null, and bigint. Almost every developer can name the other six but very few have written Symbol() outside a tutorial. That's a shame, because Symbols power some of the most useful protocols in the language (iterators, async iterators, well-known operations) and solve real problems for library authors.

This is Part 14 of the JavaScript Fundamentals series.

What a Symbol is #

A Symbol is a guaranteed-unique primitive value. Every call to Symbol() produces a new value not equal to any other:

const a = Symbol('id');
const b = Symbol('id');
a === b; // false — even though both have the same description
a === a; // true  — same reference

The optional argument ('id' here) is a description — used for debugging and toString(). It is NOT an identifier; two symbols with the same description are still different.

The four key properties #

  1. Uniqueness — no two calls to Symbol() produce equal values.
  2. Primitivetypeof Symbol() === 'symbol'. Symbols are not objects.
  3. Usable as object keysobj[mySymbol] = value works.
  4. Hidden from normal iterationfor...in, Object.keys, and JSON.stringify skip Symbol-keyed properties.

That combination is what makes Symbols useful.

Use case 1: Truly private-ish properties #

Before #privateFields existed, Symbols were the standard way to add properties to an object without polluting its iteration surface:

const _internal = Symbol('internal');

class User {
  constructor(name) {
    this.name = name;
    this[_internal] = { authToken: 'secret' };
  }
}

const u = new User('Alice');
Object.keys(u);            // ['name'] — symbol key hidden
JSON.stringify(u);         // '{"name":"Alice"}' — symbol key hidden
u[_internal];              // { authToken: 'secret' } — accessible if you have the symbol

Is it actually private? No — Object.getOwnPropertySymbols(u) returns the symbol keys. But it is invisible by default. Code that doesn't know to ask for symbols won't accidentally see or modify these properties. Compared to a string key like _internal (which any developer could autocomplete to), this is a meaningful soft-privacy improvement.

Class #fields are now the cleaner solution for hard privacy, but Symbol-keyed properties still appear in:

  • Library APIs that need to attach metadata without naming collisions.
  • Mixin frameworks that want to mark instances without using a string flag.
  • Code that needs to work with plain object literals (no class).

Use case 2: Well-known Symbols (the iterator protocol) #

JavaScript reserves a small set of symbols on Symbol.* that customize built-in language behavior. The most important is Symbol.iterator:

const rangeOneTwoThree = {
  [Symbol.iterator]() {
    let i = 1;
    return {
      next() {
        return i <= 3 ? { value: i++, done: false } : { done: true };
      },
    };
  },
};

[...rangeOneTwoThree];                 // [1, 2, 3]
for (const n of rangeOneTwoThree) {}   // 1, 2, 3
Array.from(rangeOneTwoThree);          // [1, 2, 3]

Any object with a [Symbol.iterator] method becomes iterable. Spread, for...of, destructuring, Array.from — all of these consult that specific symbol.

It's a protocol: "if you want to be iterable, implement this exact key with this exact return shape." The choice to use a Symbol (instead of a string like 'iterator') means no object can accidentally implement the protocol because it happened to have a method named iterator.

We go deeper into iterators (and the cleaner function* generator syntax) in Part 15.

The other well-known Symbols #

Symbol What it does
Symbol.iterator makes an object iterable (for...of, spread, etc)
Symbol.asyncIterator same for for await...of
Symbol.toPrimitive customize coercion to string/number/default
Symbol.toStringTag customize Object.prototype.toString.call(obj)
Symbol.hasInstance customize obj instanceof MyClass
Symbol.species tells methods like map what constructor to use for derived objects
Symbol.isConcatSpreadable controls how Array.concat handles this object
Symbol.unscopables legacy compatibility for with statements (ignore)

Most JavaScript code never touches these. Library authors use them constantly.

Example: Symbol.toPrimitive #

const price = {
  amount: 9.99,
  currency: 'USD',
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `${this.currency} ${this.amount}`;
    return this.amount;
  },
};

+price;        // 9.99    (number hint from unary +)
`${price}`;    // 'USD 9.99' (string hint from template literal)
price + 1;     // 10.99   (default hint from +, then coerced to number)

This lets a custom object behave naturally in arithmetic and string contexts without .toString() boilerplate. See our post on type coercion for the coercion rules driving these hints.

Example: Symbol.toStringTag #

class Money {
  get [Symbol.toStringTag]() { return 'Money'; }
}
Object.prototype.toString.call(new Money()); // '[object Money]'

Useful for debugging — Money instances now print as [object Money] instead of [object Object]. Internally, Array, Map, Set, Promise, etc all use this to identify themselves.

Use case 3: The Symbol registry (cross-realm sharing) #

Normal Symbol() calls return a fresh symbol every time. There's a separate global registry accessed via Symbol.for():

const a = Symbol.for('com.acme.user');
const b = Symbol.for('com.acme.user');
a === b; // true — same registry entry

Symbol.keyFor(a); // 'com.acme.user' — reverse lookup

The registry is shared across realms (iframes, web workers in some cases, vm contexts in Node). If a library wants to mark instances as "belonging to me" across iframe boundaries, Symbol.for('com.lib.brand') is the canonical pattern.

Use a namespaced description ('com.acme.user', not 'user') to avoid collisions with other libraries.

Symbols in for...of, Object.keys, JSON.stringify #

A quick reference for what sees Symbol-keyed properties:

Operation Sees symbol keys?
Object.keys(obj)
Object.values(obj)
Object.entries(obj)
for...in
JSON.stringify(obj)
Object.assign({}, obj)
{...obj} (spread)
Reflect.ownKeys(obj)
Object.getOwnPropertySymbols(obj) ✅ (only symbols)

Spread and Object.assign do copy symbol keys, which can surprise you. If you spread an object and pass the result to JSON, the symbol-keyed values get silently dropped by the JSON step.

Common gotchas #

Gotcha 1: Symbols cannot be implicitly coerced to strings.

const s = Symbol('x');
`hi ${s}`;   // TypeError — Cannot convert a Symbol value to a string
String(s);   // 'Symbol(x)' — explicit coercion works

Gotcha 2: JSON.stringify silently drops Symbol values too, not just keys.

JSON.stringify({ id: Symbol('x'), name: 'a' }); // '{"name":"a"}'

The id field is silently omitted because the value is a Symbol.

Gotcha 3: === is the only equality. There is no "value equality" for Symbols. Two Symbol('x') calls are never == or ===. Use Symbol.for(...) if you need shared identity.

Gotcha 4: WeakMap keys can be (non-registered) Symbols since ES2023. Before this, only objects worked as WeakMap keys. Now non-registry Symbols (those from plain Symbol(), not Symbol.for()) are also valid — useful for tagging without a wrapper object.

Should you use Symbols in your codebase? #

Rarely. Most application code doesn't need them. The places they earn their keep:

  • Implementing a custom iterator (Symbol.iterator).
  • Library code that needs to attach metadata to consumer objects without naming collisions.
  • Type-safe brands ("this object came from my library") — sometimes more elegant than instanceof.
  • Implementing a small DSL or protocol (think Redux Toolkit's action-type symbol pattern).

Default to strings. Reach for Symbols when you specifically need uniqueness or invisibility from normal iteration.

Recap #

  • Symbols are guaranteed-unique primitives. typeof Symbol() === 'symbol'.
  • They can be used as object keys and are hidden from Object.keys, for...in, and JSON.
  • Well-known Symbols (Symbol.iterator, Symbol.toPrimitive, etc) customize built-in protocols.
  • Symbol.for(name) uses a global registry — same name returns the same Symbol across realms.
  • Symbols don't coerce implicitly to strings, and JSON drops both symbol keys and symbol values.
  • Default to strings; use Symbols when you specifically need uniqueness or protocol implementation.

Next up: Iterators and Generators — the protocols (built on Symbol.iterator) that make lazy sequences, infinite ranges, and stream processing possible in vanilla JS.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *