Ch. 1 · JavaScript

JavaScript Symbols and Well-Known Symbols

What JavaScript symbols are for, why they hide from JSON but aren't private, and how Symbol.iterator and toPrimitive hook into the language.

~7 min readintermediateupdated Oct 19, 2026

Symbols arrived in ES2015 as the seventh primitive type, and many developers have used them for years without ever writing Symbol(...) themselves. Every for...of loop calls a symbol-keyed method. So does every spread of a Map, every Array.from on a Set, and every template literal that formats a Date. Symbols are the mechanism the language uses to let objects customise built-in behaviour without risking name collisions.

Interview questions on symbols usually come in two parts. First, what is a symbol and why would you use one as a property key? Second, how do the well-known symbols work, typically through “make this object iterable”. This guide covers both, along with the misconception that symbol properties are private.

A primitive that’s guaranteed unique

You create a symbol by calling Symbol as a function, with an optional description:

const a = Symbol('id');
const b = Symbol('id');

console.log(a === b);          // false
console.log(a.toString());     // Symbol(id)
console.log(a.description);    // id
console.log(typeof a);         // symbol
JavaScript

Two symbols with the same description are still different values. The description exists only for debugging; it shows up in logs and stack traces but plays no part in equality.

A few rules keep symbols from being mistaken for strings. new Symbol() throws TypeError: Symbol is not a constructor, since symbols are primitives and there’s no reason to wrap one. And a symbol won’t silently become a string: `${a}` throws TypeError: Cannot convert a Symbol value to a string. That strictness is useful, because a symbol accidentally concatenated into a cache key would otherwise produce "Symbol(id)" and collide with every other symbol of the same description. When you do want text, call String(a) or read a.description explicitly.

Symbol keys: hidden from most tools, but not private

Symbols can be used as property keys, using computed-property syntax:

const meta = Symbol('meta');
const user = { name: 'Ana', [meta]: { fetchedAt: 1 } };

console.log(Object.keys(user));                   // [ 'name' ]
console.log(JSON.stringify(user));                // {"name":"Ana"}
for (const k in user) console.log(k);             // name
console.log(Object.getOwnPropertySymbols(user));  // [ Symbol(meta) ]
console.log(Reflect.ownKeys(user));               // [ 'name', Symbol(meta) ]
JavaScript

Symbol-keyed properties don’t show up in Object.keys, Object.entries, for...in or JSON.stringify. That makes them a good place to attach metadata to objects you don’t own. A data-fetching layer can tag each response object with a [meta] property recording when it was fetched, and that tag won’t appear when the object is serialised and sent to a server, rendered as a table, or iterated by some other library.

The uniqueness is the bigger benefit. If two libraries both add a cache string property to your objects, they overwrite each other. If each creates its own Symbol('cache'), the keys are different values and can’t collide, even though they print the same.

Why “private” is the wrong word

A common interview answer says symbol properties are private. They’re not. Object.getOwnPropertySymbols and Reflect.ownKeys list them, and anyone with the object can read the values through those keys. They’re also copied by object spread and Object.assign: ({ ...user })[meta] still holds { fetchedAt: 1 }.

The accurate description is that symbol keys are non-colliding and out of the way of ordinary string-based code. For real privacy, use #private class fields, which can’t be accessed from outside the class at all, or a WeakMap keyed by the object. Saying this clearly is one of the easier ways to stand out on this topic.

Registry symbols with Symbol.for

Sometimes you need the same symbol in places that can’t share a variable: two copies of a library bundled separately, a main page and an iframe, or a library and the tool that inspects it. Symbol.for(key) looks up a global registry and returns the same symbol for the same string, creating it on first use:

console.log(Symbol.for('app.cache') === Symbol.for('app.cache'));  // true
console.log(Symbol.keyFor(Symbol.for('app.cache')));              // app.cache
console.log(Symbol.keyFor(Symbol('id')));                         // undefined
JavaScript

The trade-off is that registry symbols are only as unique as their string keys, so namespace them ('mylib.cache', not 'cache'). Node uses this for its inspect hook: a class that defines a method under Symbol.for('nodejs.util.inspect.custom') controls how console.log prints it. That’s handy for keeping secrets out of logs; a Secret class with that method can print Secret(***) instead of the raw value.

Well-known symbols: hooks into the language

The Symbol constructor has static properties holding predefined symbols that the language itself looks for. In Node 22 there are fifteen: asyncDispose, asyncIterator, dispose, hasInstance, isConcatSpreadable, iterator, match, matchAll, replace, search, species, split, toPrimitive, toStringTag and unscopables. When syntax or a built-in function needs to know how an object behaves, it checks for a method under one of these keys.

Using symbols for these hooks was a deliberate design choice. If iteration had been keyed by a string like "iterator", every existing object that happened to have an iterator property would suddenly have changed behaviour in ES2015. A new symbol couldn’t break any existing code.

Making an object iterable

Symbol.iterator is the one you’ll write most. Here’s a numeric range that works with every iteration-based feature:

class Range {
  constructor(from, to) { this.from = from; this.to = to; }

  *[Symbol.iterator]() {
    for (let i = this.from; i <= this.to; i++) yield i;
  }
}

const r = new Range(1, 4);
console.log([...r], Math.max(...r), Array.from(r, x => x * 2));
// [ 1, 2, 3, 4 ] 4 [ 2, 4, 6, 8 ]
const [first, second] = r;
console.log(first, second);  // 1 2
JavaScript

The method must return an iterator: an object with a next() method that returns { value, done }. A generator function does that for you, which is why *[Symbol.iterator]() is the common way to write it. Once the method exists, spread, for...of, array destructuring, Array.from, new Set(r) and Promise.all(r) all work, because each of them begins by calling obj[Symbol.iterator]().

Without it, you get the familiar error: spreading a plain { from: 1, to: 3 } throws TypeError: plain is not iterable (V8 uses the variable name). Breaking out of a for...of early is fine; with a generator, the loop calls the iterator’s return() method so any finally blocks in the generator run. Symbol.asyncIterator is the async counterpart, used by for await...of to consume streams and paginated APIs.

Controlling conversion and labels

Two more hooks change how an object looks to the rest of the language:

class Range {
  // ...iterator from above...
  get [Symbol.toStringTag]() { return 'Range'; }
  [Symbol.toPrimitive](hint) {
    return hint === 'number' ? this.to - this.from + 1 : `${this.from}..${this.to}`;
  }
}

console.log(Object.prototype.toString.call(r));  // [object Range]
console.log(`${r}`, +r);                         // 1..4 4
JavaScript

Symbol.toStringTag changes the label in Object.prototype.toString, which is how some libraries and debuggers identify types. Symbol.toPrimitive receives a hint ('number', 'string' or 'default') whenever the object is converted to a primitive. Unary + asks for a number, a template literal asks for a string, and + with a string or == asks for 'default'. Date uses this hook: it’s why date + '' gives a readable date string while date - 0 gives a timestamp.

Use toPrimitive sparingly. An object that quietly turns into a number in arithmetic is clever in a demo and confusing in a code review.

The rest, briefly

Symbol.hasInstance lets a class decide what instanceof means: a class with static [Symbol.hasInstance](n) { return Number.isInteger(n) && n % 2 === 0; } makes 4 instanceof Even true. Symbol.isConcatSpreadable controls whether concat flattens an array-like object. Symbol.match, replace, search and split let an object stand in for a regex in string methods. Symbol.species controls which constructor map and filter use to build results for subclasses; it’s widely regarded as a mistake, and engines have considered removing it for security and performance reasons. Symbol.dispose and Symbol.asyncDispose support the newer using declarations for deterministic cleanup.

Interview exercise: make a paginated API iterable

Question. You have fetchPage(cursor) that returns { items, nextCursor }, with nextCursor set to null on the last page. Design an object that lets callers write for await (const order of orders) { ... } and fetch only as many pages as they actually consume.

Answer. Implement Symbol.asyncIterator with an async generator:

const orders = {
  async *[Symbol.asyncIterator]() {
    let cursor = undefined;
    do {
      const { items, nextCursor } = await fetchPage(cursor);
      yield* items;
      cursor = nextCursor;
    } while (cursor !== null);
  },
};
JavaScript

for await calls orders[Symbol.asyncIterator]() to get an iterator, then awaits next() for each item. The generator pauses at each yield, so the next page is fetched only when the caller has consumed every item on the current one. If the caller breaks after the first five orders, the loop calls the iterator’s return(), the generator stops, and no further pages are requested. Each loop gets a fresh generator, so two loops over orders don’t share a cursor. Explaining why a symbol is used here (so the hook can’t clash with an orders.iterator property someone else defines) and why the generator handles laziness for free covers what the interviewer is looking for. A thorough answer adds error handling: a failed fetchPage rejects the pending next(), which surfaces as an exception inside the for await loop.

More in JavaScript

esc