Symbols & Well-Known Symbols
Symbols are a primitive type introduced in ES2015 that produce values guaranteed to be unique. Beyond serving as collision-proof property keys, a special family called well-known symbols acts as hooks into the language itself: by defining a method keyed by one of these symbols, you can change how your object behaves with operators like for...of, instanceof, +, and String(). This is the most accessible form of metaprogramming in JavaScript, and it lets your custom objects participate in built-in syntax as first-class citizens.
What a symbol is
Every call to Symbol() returns a brand-new, unique value, even if you pass the same description. The description is purely for debugging. Because symbols are unique, they make ideal property keys when you want to attach data without risking name clashes.
const id = Symbol("id");
const sameDescription = Symbol("id");
console.log(id === sameDescription); // false
console.log(typeof id); // "symbol"
const user = { name: "Ada", [id]: 42 };
console.log(user[id]); // 42
Output:
false
symbol
42
Symbol-keyed properties are skipped by for...in, Object.keys(), and JSON.stringify(). Use Object.getOwnPropertySymbols() to inspect them.
Well-known symbols overview
Well-known symbols are static properties on the Symbol object. The engine looks them up on your objects at specific moments to decide how an operation behaves.
| Symbol | Hooks into | When it runs |
|---|---|---|
Symbol.iterator | for...of, spread ..., destructuring | Synchronous iteration |
Symbol.asyncIterator | for await...of | Asynchronous iteration |
Symbol.toPrimitive | +, <, template strings, String()/Number() | Type coercion |
Symbol.hasInstance | instanceof | Instance checks |
Symbol.toStringTag | Object.prototype.toString | Default string tag |
Making an object iterable
Implementing Symbol.iterator is what lets any object work with for...of, the spread operator, and array destructuring. The method must return an iterator: an object with a next() method that yields { value, done } records. A generator function is the cleanest way to produce one.
const range = {
start: 1,
end: 4,
[Symbol.iterator]() {
let current = this.start;
const last = this.end;
return {
next() {
return current <= last
? { value: current++, done: false }
: { value: undefined, done: true };
},
};
},
};
console.log([...range]); // [1, 2, 3, 4]
for (const n of range) console.log(n);
Output:
[ 1, 2, 3, 4 ]
1
2
3
4
For async sources, implement Symbol.asyncIterator so the object works with for await...of. Its next() returns a promise.
const ticker = {
[Symbol.asyncIterator]() {
let count = 0;
return {
next() {
return count < 3
? new Promise((resolve) =>
setTimeout(() => resolve({ value: ++count, done: false }), 100),
)
: Promise.resolve({ value: undefined, done: true });
},
};
},
};
for await (const tick of ticker) {
console.log(`tick ${tick}`);
}
Output:
tick 1
tick 2
tick 3
Controlling coercion with Symbol.toPrimitive
When an object is used in a numeric, string, or default context, the engine calls Symbol.toPrimitive with a hint of "number", "string", or "default". This gives you precise control instead of relying on the legacy valueOf/toString pair.
const money = {
amount: 1500,
currency: "USD",
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.amount;
if (hint === "string") return `${this.currency} ${this.amount}`;
return `${this.amount} ${this.currency}`; // default
},
};
console.log(+money); // number hint
console.log(`${money}`); // string hint
console.log(money + " total"); // default hint
Output:
1500
USD 1500
1500 USD total
The
"default"hint fires for+and==. If you only need one representation, returning the same value for every hint is perfectly valid.
Customizing instanceof and the string tag
instanceof normally walks the prototype chain, but Symbol.hasInstance lets a class or object define the check itself. This is useful for duck-typing validators.
class Even {
static [Symbol.hasInstance](value) {
return Number.isInteger(value) && value % 2 === 0;
}
}
console.log(4 instanceof Even); // true
console.log(7 instanceof Even); // false
Output:
true
false
Symbol.toStringTag overrides the tag that Object.prototype.toString produces, which is what tools and console use to label exotic objects.
class Matrix {
get [Symbol.toStringTag]() {
return "Matrix";
}
}
console.log(Object.prototype.toString.call(new Matrix()));
Output:
[object Matrix]
Global and registered symbols
Symbol.for(key) looks up a symbol in a process-wide registry, creating it once and returning the same value on every subsequent call. Unlike Symbol(), this is shared across modules and even realms (such as iframes). Use Symbol.keyFor() to recover the key.
const a = Symbol.for("app.config");
const b = Symbol.for("app.config");
console.log(a === b); // true
console.log(Symbol.keyFor(a)); // "app.config"
Output:
true
app.config
Best practices
- Reach for well-known symbols when you want custom objects to behave naturally with built-in syntax, not to obscure intent.
- Prefer a generator function for
Symbol.iterator; it removes the boilerplate of writingnext()by hand. - Implement
Symbol.toPrimitiveinstead of overriding bothvalueOfandtoStringwhen you need full coercion control. - Use plain
Symbol()for private-ish, per-object metadata andSymbol.for()only when a value must be shared across modules or realms. - Remember that symbol keys are hidden from
JSON.stringify,Object.keys, andfor...in; do not store data you expect to serialize as symbol properties. - Keep
Symbol.hasInstancechecks pure and fast, sinceinstanceofis often used in hot paths and guard clauses.