A symbol is a primitive value that is guaranteed to be unique. Its main job is to be a property key that cannot collide with any other key, including keys added by other code or by future versions of the language. The language itself uses a set of well-known symbols, such as Symbol.iterator, as hooks that let your objects take part in built-in behaviour.
Creating symbols
Call Symbol(), optionally with a description. There is no new; symbols are primitives, like numbers and strings.
const id = Symbol('id');
const otherId = Symbol('id');
console.log(typeof id); // → symbol
console.log(id === otherId); // → false
console.log(id.description); // → id
console.log(id.toString()); // → Symbol(id)
The description is only a label for debugging. Two symbols with the same description are still different values.
Symbols do not convert to strings automatically, which protects you from accidentally using one as text:
try {
console.log(`Key: ${id}`);
} catch (error) {
console.log(error instanceof TypeError); // → true
}
console.log(`Key: ${String(id)}`); // → Key: Symbol(id)
Symbols as property keys
Use a symbol as a key with square brackets, both in object literals and when accessing the property:
const internalId = Symbol('internalId');
const course = {
title: 'JavaScript',
[internalId]: 'c-42',
};
console.log(course[internalId]); // → c-42
console.log(course.internalId); // → undefined (a string key, not the symbol)
Symbol-keyed properties are skipped by most of the ways code looks at an object:
console.log(Object.keys(course)); // → [ 'title' ]
console.log(JSON.stringify(course)); // → {"title":"JavaScript"}
for (const key in course) {
console.log(key); // → title
}
console.log(Object.getOwnPropertySymbols(course)); // → [ Symbol(internalId) ]
console.log(Reflect.ownKeys(course)); // → [ 'title', Symbol(internalId) ]
This makes symbols good for metadata that should not get in the way: a library can attach information to objects it does not own without it showing up in loops, JSON output or the owner's own keys. Object spread and Object.assign do copy enumerable symbol properties, so the metadata travels with shallow copies.
Symbols are not a privacy mechanism. Anyone can find symbol keys with Object.getOwnPropertySymbols or Reflect.ownKeys. For truly private data in a class, use private fields (#name).
The global symbol registry
Sometimes two separate pieces of code, such as two bundles on one page or code in an iframe, need to agree on the same symbol. Symbol.for(key) looks the key up in a registry shared across the whole JavaScript environment, and creates the symbol only if it does not exist yet:
const a = Symbol.for('geeks.cache');
const b = Symbol.for('geeks.cache');
console.log(a === b); // → true
console.log(Symbol.keyFor(a)); // → geeks.cache
console.log(Symbol.keyFor(Symbol('local'))); // → undefined
Registered symbols trade uniqueness for shareability: any code using the same string gets the same symbol, so namespace your keys ('geeks.cache', not 'cache'). Use plain Symbol() unless you specifically need sharing.
Well-known symbols
The language defines a set of symbols as static properties of Symbol. Built-in operations look for methods under these keys, so implementing one changes how the language treats your object. The most useful:
| Symbol | Used by | Lets you |
|---|---|---|
Symbol.iterator | for...of, spread, destructuring | make an object iterable |
Symbol.asyncIterator | for await...of | make an object async iterable |
Symbol.toPrimitive | +obj, template literals, obj + 1, comparisons | control conversion to a primitive |
Symbol.toStringTag | Object.prototype.toString | name the object in [object Name] |
Symbol.hasInstance | instanceof | customise instanceof checks |
There are others, such as Symbol.species and the regular expression symbols (Symbol.match, Symbol.replace and so on), which you will rarely need.
Symbol.iterator
You met this in the iterators lesson. A collection class that exposes iteration without exposing its internals:
class CourseList {
#courses = [];
add(course) {
this.#courses.push(course);
return this;
}
*[Symbol.iterator]() {
for (const course of this.#courses) {
yield course.title;
}
}
}
const list = new CourseList().add({ title: 'HTML' }).add({ title: 'CSS' });
console.log([...list]); // → [ 'HTML', 'CSS' ]
Symbol.toPrimitive
When JavaScript needs a primitive from an object, for maths, string interpolation or comparison, it calls [Symbol.toPrimitive](hint) if it exists. The hint is 'number', 'string' or 'default':
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
if (hint === 'string') {
return `${this.amount.toFixed(2)} ${this.currency}`;
}
return this.amount;
}
}
const price = new Money(9.5, 'GBP');
console.log(`${price}`); // → 9.50 GBP (hint: 'string')
console.log(+price); // → 9.5 (hint: 'number')
console.log(price + 1); // → 10.5 (hint: 'default')
console.log(price > 5); // → true (hint: 'number')
Without this method, JavaScript falls back to valueOf() and toString(), and putting price in a template literal would print [object Object]. Use this sparingly: implicit conversion can surprise readers, and an explicit price.format() method is often clearer. It is most useful for value-like objects such as money, durations or measurements.
Symbol.toStringTag
Object.prototype.toString reports an object's type as [object Name]. A getter under Symbol.toStringTag sets the name, which makes logs and type checks clearer:
class Queue {
get [Symbol.toStringTag]() {
return 'Queue';
}
}
console.log(String(new Queue())); // → [object Queue]
console.log(Object.prototype.toString.call(new Queue())); // → [object Queue]
Built-ins use the same hook: Object.prototype.toString.call(new Map()) gives [object Map].
Symbol.hasInstance
A static method under Symbol.hasInstance decides what instanceof returns. It turns instanceof into a general type check:
class EvenNumber {
static [Symbol.hasInstance](value) {
return Number.isInteger(value) && value % 2 === 0;
}
}
console.log(4 instanceof EvenNumber); // → true
console.log(7 instanceof EvenNumber); // → false
console.log('4' instanceof EvenNumber); // → false
This is clever, but instanceof normally means "was created by this class", and redefining it can confuse readers. A plain function such as isEven(value) is usually the better choice; know the hook exists so you recognise it in libraries.
Practical uses
- Collision-free extension points. A library can define
const serialise = Symbol('serialise')and callobj[serialise]()if present. Users implement the method under that symbol, and it can never clash with an existingserialiseproperty. - Metadata on objects you do not own. Attach a symbol-keyed property to mark objects you have processed, without affecting their JSON or keys. Alternatively, use a
WeakMapkeyed by the object, which leaves the object untouched altogether. - Unique constants. Symbols make good enum-like values that cannot be confused with strings:
const Status = Object.freeze({ Loading: Symbol('loading'), Ready: Symbol('ready') }). They cannot be serialised to JSON, so use strings for values that are saved or sent over the network.
Since ES2023, non-registered symbols can also be used as WeakMap keys. Registered symbols from Symbol.for cannot, because they never become unreachable.
Summary
Symbol(description)creates a unique primitive; the description is only a label.- Symbol-keyed properties are skipped by
Object.keys,for...inandJSON.stringify, but are not private. Symbol.for(key)returns a shared, registered symbol; namespace the key.- Well-known symbols such as
Symbol.iterator,Symbol.toPrimitive,Symbol.toStringTagandSymbol.hasInstancelet objects plug into built-in behaviour. - Use symbols for collision-free keys and extension points, and prefer explicit methods when implicit behaviour would surprise readers.