JavaScript — Symbols
Symbols are a primitive data type introduced in ES2015, designed to create unique, immutable identifiers. Unlike strings or numbers, every symbol is guaranteed to be unique — even symbols created with the same description are different values. This uniqueness makes symbols ideal for avoiding property name collisions.
Beyond simple uniqueness, symbols power JavaScript's protocol system through well-known symbols like Symbol.iterator, Symbol.toStringTag, and Symbol.hasInstance. These symbols allow objects to customize their behavior in language-level operations.
This page covers creating symbols, the symbol registry, all well-known symbols, and practical use cases.
| 1 | // Creating symbols — each is unique |
| 2 | const sym1 = Symbol(); |
| 3 | const sym2 = Symbol("debug-name"); |
| 4 | |
| 5 | console.log(typeof sym1); // "symbol" |
| 6 | console.log(sym1 === sym2); // false — always unique |
| 7 | console.log(sym2.description); // "debug-name" |
| 8 | |
| 9 | // Symbols as object keys — hidden from normal enumeration |
| 10 | const obj = { |
| 11 | [sym1]: "secret value", |
| 12 | visible: "public value" |
| 13 | }; |
| 14 | |
| 15 | console.log(obj[sym1]); // "secret value" |
| 16 | console.log(obj.visible); // "public value" |
| 17 | console.log(Object.keys(obj)); // ["visible"] |
| 18 | console.log(Object.getOwnPropertyNames(obj)); // ["visible"] |
| 19 | console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(debug-name)] |
The Symbol() function creates a new unique symbol. An optional description string can be provided for debugging — it is accessible via the description property but does not affect uniqueness. There are two ways to create symbols: local symbols via Symbol() and shared symbols via Symbol.for().
| Method | Description | Uniqueness |
|---|---|---|
| Symbol() | Creates a unique unnamed symbol | Always unique |
| Symbol("desc") | Creates a unique symbol with description | Always unique |
| Symbol.for("key") | Retrieves or creates a global registry symbol | Shared (same key = same symbol) |
| Symbol.keyFor(sym) | Returns the key for a registry symbol | — |
| 1 | // Local symbols — always unique |
| 2 | const a = Symbol("id"); |
| 3 | const b = Symbol("id"); |
| 4 | |
| 5 | console.log(a === b); // false — different symbols |
| 6 | console.log(a.description); // "id" |
| 7 | console.log(b.description); // "id" |
| 8 | |
| 9 | // Global registry symbols — shared across realms |
| 10 | const shared = Symbol.for("app.api.key"); |
| 11 | const same = Symbol.for("app.api.key"); |
| 12 | |
| 13 | console.log(shared === same); // true — same symbol |
| 14 | console.log(Symbol.keyFor(shared)); // "app.api.key" |
| 15 | |
| 16 | // Symbols are not auto-converted to strings |
| 17 | try { |
| 18 | console.log("Symbol: " + a); |
| 19 | } catch (e) { |
| 20 | console.log(e.message); |
| 21 | // Cannot convert a Symbol value to a string |
| 22 | } |
| 23 | |
| 24 | // Must explicitly call toString() or use description |
| 25 | console.log(a.toString()); // "Symbol(id)" |
| 26 | console.log(String(a)); // "Symbol(id)" |
| 27 | |
| 28 | // Symbols in object literals |
| 29 | const user = { |
| 30 | name: "Alice", |
| 31 | [Symbol("id")]: 12345, |
| 32 | [Symbol.for("role")]: "admin" |
| 33 | }; |
| 34 | |
| 35 | // Object.getOwnPropertySymbols reveals symbol keys |
| 36 | const symbols = Object.getOwnPropertySymbols(user); |
| 37 | console.log(symbols.length); // 2 |
info
JavaScript defines built-in symbols that act as protocol hooks. When an object defines a method with a well-known symbol key, JavaScript's runtime uses that method for specific language operations. These are the foundation of protocols like iteration, string coercion, and type checking.
| Symbol | Purpose | Used By |
|---|---|---|
| Symbol.iterator | Returns default iterator | for..of, spread, yield* |
| Symbol.asyncIterator | Returns async iterator | for await..of |
| Symbol.toStringTag | Custom Object.prototype.toString | Object.prototype.toString |
| Symbol.hasInstance | Custom instanceof behavior | instanceof operator |
| Symbol.toPrimitive | Custom type coercion | Type conversion (String, Number, etc.) |
| Symbol.species | Constructor for derived objects | Array.map, Promise.then, etc. |
| Symbol.match | Custom pattern matching | String.prototype.match |
| Symbol.replace | Custom string replacement | String.prototype.replace |
| Symbol.search | Custom string search | String.prototype.search |
| Symbol.split | Custom string split | String.prototype.split |
| Symbol.isConcatSpreadable | Control flattening in concat | Array.prototype.concat |
| Symbol.unscopables | Exclude properties from with | with statement |
Symbol.iterator — Custom Iteration
| 1 | // Making an object iterable with Symbol.iterator |
| 2 | const range = { |
| 3 | start: 1, |
| 4 | end: 5, |
| 5 | [Symbol.iterator]() { |
| 6 | let current = this.start; |
| 7 | const end = this.end; |
| 8 | return { |
| 9 | next() { |
| 10 | if (current <= end) { |
| 11 | return { value: current++, done: false }; |
| 12 | } |
| 13 | return { value: undefined, done: true }; |
| 14 | } |
| 15 | }; |
| 16 | } |
| 17 | }; |
| 18 | |
| 19 | // Now works with for..of and spread |
| 20 | for (const n of range) { |
| 21 | console.log(n); // 1, 2, 3, 4, 5 |
| 22 | } |
| 23 | |
| 24 | console.log([...range]); // [1, 2, 3, 4, 5] |
| 25 | |
| 26 | // Async iterator with Symbol.asyncIterator |
| 27 | const asyncRange = { |
| 28 | start: 1, |
| 29 | end: 3, |
| 30 | [Symbol.asyncIterator]() { |
| 31 | let current = this.start; |
| 32 | const end = this.end; |
| 33 | return { |
| 34 | async next() { |
| 35 | await new Promise(r => setTimeout(r, 100)); |
| 36 | if (current <= end) { |
| 37 | return { value: current++, done: false }; |
| 38 | } |
| 39 | return { value: undefined, done: true }; |
| 40 | } |
| 41 | }; |
| 42 | } |
| 43 | }; |
| 44 | |
| 45 | // for await (const n of asyncRange) { console.log(n); } |
Symbol.toStringTag & Symbol.hasInstance
| 1 | // Custom toString tag |
| 2 | class MyCollection { |
| 3 | get [Symbol.toStringTag]() { |
| 4 | return "MyCollection"; |
| 5 | } |
| 6 | } |
| 7 | |
| 8 | const c = new MyCollection(); |
| 9 | console.log(Object.prototype.toString.call(c)); |
| 10 | // "[object MyCollection]" |
| 11 | |
| 12 | // Without Symbol.toStringTag: |
| 13 | // "[object Object]" |
| 14 | |
| 15 | // Custom instanceof behavior |
| 16 | class Zero { |
| 17 | static [Symbol.hasInstance](instance) { |
| 18 | return instance === 0 || instance?.valueOf?.() === 0; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | console.log(0 instanceof Zero); // true |
| 23 | console.log(new Number(0) instanceof Zero); // true |
| 24 | console.log(1 instanceof Zero); // false |
| 25 | |
| 26 | // Symbol.toPrimitive — custom type coercion |
| 27 | const temperature = { |
| 28 | value: 25, |
| 29 | unit: "C", |
| 30 | [Symbol.toPrimitive](hint) { |
| 31 | if (hint === "string") return `${this.value}°${this.unit}`; |
| 32 | if (hint === "number") return this.value; |
| 33 | return this.value; |
| 34 | } |
| 35 | }; |
| 36 | |
| 37 | console.log(String(temperature)); // "25°C" |
| 38 | console.log(+temperature); // 25 |
| 39 | console.log(`Temp: ${temperature}`); // "Temp: 25°C" |
pro tip
The global symbol registry is a cross-realm symbol pool managed by Symbol.for() and Symbol.keyFor(). Symbols stored here are shared across all realms (iframes, workers, etc.) and can be accessed by their string key. This is distinct from creating symbols with Symbol(), which are always unique and private.
| 1 | // Global symbol registry — cross-realm sharing |
| 2 | // Symbol.for(key) — returns existing or creates new |
| 3 | const sym1 = Symbol.for("app.config"); |
| 4 | const sym2 = Symbol.for("app.config"); |
| 5 | |
| 6 | console.log(sym1 === sym2); // true — same symbol |
| 7 | |
| 8 | // Symbol.keyFor(sym) — get key from registry symbol |
| 9 | console.log(Symbol.keyFor(sym1)); // "app.config" |
| 10 | |
| 11 | // Non-registry symbols return undefined for keyFor |
| 12 | const local = Symbol("app.config"); |
| 13 | console.log(Symbol.keyFor(local)); // undefined |
| 14 | |
| 15 | // Registry symbols survive across realms |
| 16 | // In an iframe: Symbol.for("app.config") === sym1 → true |
| 17 | |
| 18 | // Practical: API for registering extension points |
| 19 | const ExtensionPoints = { |
| 20 | register(name) { |
| 21 | return Symbol.for(`ext.${name}`); |
| 22 | }, |
| 23 | get(name) { |
| 24 | const sym = Symbol.for(`ext.${name}`); |
| 25 | return Symbol.keyFor(sym) ? sym : undefined; |
| 26 | } |
| 27 | }; |
| 28 | |
| 29 | const beforeRender = ExtensionPoints.register("beforeRender"); |
| 30 | const afterRender = ExtensionPoints.register("afterRender"); |
| 31 | |
| 32 | // Now any module can share these symbols |
| 33 | const plugin = { |
| 34 | [beforeRender]: (ctx) => console.log("pre", ctx), |
| 35 | [afterRender]: (ctx) => console.log("post", ctx) |
| 36 | }; |
| 37 | |
| 38 | console.log(ExtensionPoints.get("beforeRender") === beforeRender); |
| 39 | // true |
warning
Private Properties
Before private class fields (#property), symbols were the primary way to create non-enumerable, hard-to-access properties. They are not truly private (accessible via Object.getOwnPropertySymbols), but they avoid accidental name collisions.
| 1 | // Symbols as pseudo-private properties |
| 2 | const _id = Symbol("id"); |
| 3 | const _cache = Symbol("cache"); |
| 4 | |
| 5 | class DataStore { |
| 6 | constructor() { |
| 7 | this[_id] = crypto.randomUUID(); |
| 8 | this[_cache] = new Map(); |
| 9 | this.name = "Public"; |
| 10 | } |
| 11 | |
| 12 | get id() { return this[_id]; } |
| 13 | |
| 14 | setCached(key, value) { |
| 15 | this[_cache].set(key, value); |
| 16 | } |
| 17 | |
| 18 | getCached(key) { |
| 19 | return this[_cache].get(key); |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | const store = new DataStore(); |
| 24 | |
| 25 | // Symbol properties not visible in normal iteration |
| 26 | console.log(Object.keys(store)); // ["name"] |
| 27 | console.log(JSON.stringify(store)); // {"name":"Public"} |
| 28 | |
| 29 | // But accessible via getOwnPropertySymbols |
| 30 | const symbols = Object.getOwnPropertySymbols(store); |
| 31 | console.log(symbols); // [Symbol(id), Symbol(cache)] |
| 32 | |
| 33 | // This is why private class fields (#) were introduced |
| 34 | // They are truly inaccessible from outside the class |
Protocols & Extension Points
| 1 | // Symbols define protocols — any object can participate |
| 2 | const Serializable = Symbol("Serializable"); |
| 3 | |
| 4 | class User { |
| 5 | constructor(name, age) { |
| 6 | this.name = name; |
| 7 | this.age = age; |
| 8 | } |
| 9 | |
| 10 | [Serializable]() { |
| 11 | return JSON.stringify({ |
| 12 | name: this.name, |
| 13 | age: this.age |
| 14 | }); |
| 15 | } |
| 16 | } |
| 17 | |
| 18 | // Any object can implement the protocol |
| 19 | const settings = { |
| 20 | theme: "dark", |
| 21 | lang: "en", |
| 22 | [Serializable]() { |
| 23 | return JSON.stringify(this); |
| 24 | } |
| 25 | }; |
| 26 | |
| 27 | function serialize(obj) { |
| 28 | if (typeof obj[Serializable] === "function") { |
| 29 | return obj[Serializable](); |
| 30 | } |
| 31 | throw new Error("Not serializable"); |
| 32 | } |
| 33 | |
| 34 | console.log(serialize(new User("Alice", 30))); |
| 35 | console.log(serialize(settings)); |
| 36 | |
| 37 | // Metadata annotations with symbols |
| 38 | const Metadata = Symbol("metadata"); |
| 39 | |
| 40 | function annotate(target, meta) { |
| 41 | target[Metadata] = { ...target[Metadata], ...meta }; |
| 42 | return target; |
| 43 | } |
| 44 | |
| 45 | @annotate({ author: "alice", version: "1.0" }) |
| 46 | class Service { |
| 47 | execute() { /* ... */ } |
| 48 | } |
| 49 | |
| 50 | console.log(Service[Metadata]); |
| 51 | // { author: "alice", version: "1.0" } |
best practice
pro tip