5.5 Symbol and Well-known Symbols
Symbol is a primitive type added in ES6 that always has a unique value. Through Well-known Symbols, you can customize JavaScript's built-in behaviors.
Symbol Basics
// Symbols are always unique
const sym1 = Symbol();
const sym2 = Symbol();
console.log(sym1 === sym2); // false
// Add a description — for debugging
const id = Symbol("user id");
const version = Symbol("version");
console.log(id.toString()); // "Symbol(user id)"
console.log(id.description); // "user id"
// Symbols cannot be implicitly coerced
try {
const str = "prefix " + id; // TypeError!
} catch (e) {
console.log(e.message);
}
// Explicit conversion is allowed
console.log(id.toString()); // "Symbol(user id)"
console.log(String(id)); // "Symbol(user id)"
// +id → TypeError (cannot convert to number)
// `${id}` → TypeError (not allowed in template literals)
Symbols as Object Keys
Using a Symbol as an object property key lets you add metadata without clashing with regular string keys.
const TYPE = Symbol("type");
const VERSION = Symbol("version");
const INTERNAL = Symbol("internal state");
class DataModel {
constructor(data, type) {
// Public data
Object.assign(this, data);
// Symbol keys: metadata that won't clash with external code
this[TYPE] = type;
this[VERSION] = 1;
this[INTERNAL] = { dirty: false, created: Date.now() };
}
getType() { return this[TYPE]; }
getVersion() { return this[VERSION]; }
markDirty() {
this[INTERNAL].dirty = true;
}
}
const user = new DataModel({ name: "Alice", email: "alice@example.com" }, "User");
// Symbol keys are excluded from normal enumeration
console.log(Object.keys(user)); // ["name", "email"] (Symbols excluded)
console.log(JSON.stringify(user)); // '{"name":"Alice","email":"alice@example.com"}'
// Accessing Symbol keys
console.log(user.getType()); // "User"
console.log(user[TYPE]); // "User"
// Getting Symbol keys
const symbolKeys = Object.getOwnPropertySymbols(user);
console.log(symbolKeys.length); // 3
// Reflect.ownKeys: both string + Symbol keys
console.log(Reflect.ownKeys(user)); // ["name", "email", Symbol(type), ...]
Symbol.for() — Global Registry
Symbol.for() shares symbols using a key in the global symbol registry.
// Symbol.for: returns the same symbol for the same key
const s1 = Symbol.for("app.user.id");
const s2 = Symbol.for("app.user.id");
console.log(s1 === s2); // true
// Symbol(): always creates a new symbol
const s3 = Symbol("app.user.id");
console.log(s1 === s3); // false
// Reverse lookup: find key from symbol
console.log(Symbol.keyFor(s1)); // "app.user.id"
console.log(Symbol.keyFor(s3)); // undefined (not in global registry)
// Pattern for sharing symbols between libraries
// Library A
const STATUS = Symbol.for("myapp.status");
// Library B (same app, different module)
const STATUS_B = Symbol.for("myapp.status");
const obj = {};
obj[STATUS] = "active";
console.log(obj[STATUS_B]); // "active" (same symbol)
Well-known Symbol 1: Symbol.iterator
Implementing Symbol.iterator allows the object to be used with for...of, spread, and destructuring.
class Range {
constructor(start, end, step = 1) {
this.start = start;
this.end = end;
this.step = step;
}
[Symbol.iterator]() {
let current = this.start;
const { end, step } = this;
return {
next() {
if (current <= end) {
const value = current;
current += step;
return { value, done: false };
}
return { value: undefined, done: true };
},
[Symbol.iterator]() { return this; }, // The iterator itself is also iterable
};
}
// More concisely with a generator:
// *[Symbol.iterator]() {
// for (let i = this.start; i <= this.end; i += this.step) yield i;
// }
}
const range = new Range(1, 10, 2);
// for...of
for (const n of range) {
process.stdout.write(`${n} `);
}
// 1 3 5 7 9
console.log();
// Spread
console.log([...range]); // [1, 3, 5, 7, 9]
// Destructuring
const [first, second, ...rest] = range;
console.log(first, second, rest); // 1 3 [5, 7, 9]
// With Math.min/max
console.log(Math.max(...range)); // 9
Well-known Symbol 2: Symbol.toPrimitive
Customize type conversion behavior.
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
[Symbol.toPrimitive](hint) {
console.log(`hint: ${hint}`);
switch (hint) {
case "number": return this.celsius;
case "string": return `${this.celsius}°C`;
case "default": return this.celsius;
}
}
toString() { return `Temperature(${this.celsius}°C)`; }
}
const temp = new Temperature(25);
// Numeric operation (hint: "number")
console.log(temp + 5); // hint: default → 30
console.log(temp * 2); // hint: number → 50
console.log(temp > 20); // hint: number → true
// String context (hint: "string")
console.log(`Temperature: ${temp}`); // hint: string → "Temperature: 25°C"
// Comparison (hint: "default")
console.log(temp == 25); // hint: default → true
// Relationship between valueOf and Symbol.toPrimitive
class Money {
constructor(amount, currency = "USD") {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
if (hint === "string") return `${this.amount.toLocaleString()}${this.currency}`;
return this.amount;
}
}
const price = new Money(50000);
console.log(`Price: ${price}`); // "Price: 50,000USD"
console.log(price + 10000); // 60000
console.log(price > 30000); // true
Well-known Symbol 3: Symbol.hasInstance
Customize the behavior of the instanceof operator.
class TypeValidator {
static [Symbol.hasInstance](value) {
return this.validate(value);
}
}
class Integer extends TypeValidator {
static validate(value) {
return Number.isInteger(value);
}
}
class PositiveNumber extends TypeValidator {
static validate(value) {
return typeof value === "number" && value > 0 && !isNaN(value);
}
}
class NonEmptyString extends TypeValidator {
static validate(value) {
return typeof value === "string" && value.trim().length > 0;
}
}
console.log(42 instanceof Integer); // true
console.log(3.14 instanceof Integer); // false
console.log(-1 instanceof PositiveNumber); // false
console.log(10 instanceof PositiveNumber); // true
console.log("" instanceof NonEmptyString); // false
console.log("hello" instanceof NonEmptyString); // true
// Conditional type check function
function assertType(value, Type) {
if (!(value instanceof Type)) {
throw new TypeError(
`Expected type: ${Type.name}, received: ${JSON.stringify(value)}`
);
}
return value;
}
assertType(42, Integer); // OK
assertType("hi", NonEmptyString); // OK
// assertType(3.14, Integer); // TypeError!
Well-known Symbol 4: Symbol.toStringTag
Customize the result of Object.prototype.toString.call().
// Default behavior
console.log(Object.prototype.toString.call([])); // "[object Array]"
console.log(Object.prototype.toString.call(new Map())); // "[object Map]"
console.log(Object.prototype.toString.call(null)); // "[object Null]"
// Custom tag
class Collection {
get [Symbol.toStringTag]() {
return "Collection";
}
}
const col = new Collection();
console.log(Object.prototype.toString.call(col)); // "[object Collection]"
console.log(col.toString()); // "[object Collection]" (when using default toString)
// Type detection utility
function getType(value) {
return Object.prototype.toString.call(value).slice(8, -1);
}
console.log(getType([])); // "Array"
console.log(getType({})); // "Object"
console.log(getType(new Map())); // "Map"
console.log(getType(new Set())); // "Set"
console.log(getType(null)); // "Null"
console.log(getType(undefined)); // "Undefined"
console.log(getType(async () => {})); // "AsyncFunction"
console.log(getType(col)); // "Collection"
Other Well-known Symbols
// Symbol.isConcatSpreadable: whether to spread in Array.prototype.concat
const arr = [1, 2, 3];
const fakeArray = { 0: 4, 1: 5, length: 2 };
// Default: array-like objects are not spread
console.log([0].concat(fakeArray)); // [0, { 0: 4, 1: 5, length: 2 }]
fakeArray[Symbol.isConcatSpreadable] = true;
console.log([0].concat(fakeArray)); // [0, 4, 5]
// Prevent an array from spreading
const noSpread = [6, 7, 8];
noSpread[Symbol.isConcatSpreadable] = false;
console.log([1, 2].concat(noSpread)); // [1, 2, [6, 7, 8]]
// Symbol.species: control the return type of derived methods
class MyArray extends Array {
static get [Symbol.species]() { return Array; } // map etc. return Array
}
const myArr = new MyArray(1, 2, 3);
const mapped = myArr.map(x => x * 2);
console.log(mapped instanceof MyArray); // false (returns Array)
console.log(mapped instanceof Array); // true
// Symbol.unscopables: properties excluded from with statements
// (with statements are forbidden in strict mode, so limited practical use)
Practical Example: Plugin System
An extensible plugin system using Symbols.
// Define plugin interface (express contract with Symbols)
const PLUGIN_NAME = Symbol("plugin.name");
const PLUGIN_INIT = Symbol("plugin.init");
const PLUGIN_HOOKS = Symbol("plugin.hooks");
class PluginSystem {
#plugins = new Map();
#hooks = new Map();
register(plugin) {
const name = plugin[PLUGIN_NAME];
if (!name) throw new Error("Plugin is missing PLUGIN_NAME Symbol");
this.#plugins.set(name, plugin);
if (typeof plugin[PLUGIN_INIT] === "function") {
plugin[PLUGIN_INIT](this);
}
const hooks = plugin[PLUGIN_HOOKS] ?? {};
for (const [event, fn] of Object.entries(hooks)) {
if (!this.#hooks.has(event)) this.#hooks.set(event, []);
this.#hooks.get(event).push(fn.bind(plugin));
}
return this;
}
trigger(event, ...args) {
const handlers = this.#hooks.get(event) ?? [];
return handlers.map(fn => fn(...args));
}
}
// Plugin implementations
const LoggingPlugin = {
[PLUGIN_NAME]: "logging",
[PLUGIN_HOOKS]: {
"request": (url) => console.log(`[LOG] Request: ${url}`),
"response": (data) => console.log(`[LOG] Response: ${JSON.stringify(data).slice(0, 50)}`),
},
};
const MetricsPlugin = {
[PLUGIN_NAME]: "metrics",
requestCount: 0,
[PLUGIN_HOOKS]: {
"request": function(url) { this.requestCount++; },
},
};
const system = new PluginSystem();
system.register(LoggingPlugin).register(MetricsPlugin);
system.trigger("request", "/api/users");
// [LOG] Request: /api/users
system.trigger("response", [{ id: 1, name: "Alice" }]);
// [LOG] Response: [{"id":1,"name":"Alice"}]
console.log(MetricsPlugin.requestCount); // 1
Pro Tips
Tip 1: Symbols as Private Methods (pre-# pattern)
// Hide private methods using Symbols (for environments without # support)
const _validate = Symbol("validate");
const _format = Symbol("format");
class DataProcessor {
[_validate](data) {
return data !== null && data !== undefined;
}
[_format](data) {
return JSON.stringify(data, null, 2);
}
process(data) {
if (!this[_validate](data)) throw new Error("Invalid data");
return this[_format](data);
}
}
const dp = new DataProcessor();
console.log(dp.process({ name: "Alice" }));
// dp[_validate] — cannot be accessed externally without knowing the Symbol
Tip 2: Async Iterables with Symbol.asyncIterator
class AsyncDataSource {
constructor(data) {
this.data = data;
}
[Symbol.asyncIterator]() {
let index = 0;
const data = this.data;
return {
async next() {
if (index >= data.length) return { done: true, value: undefined };
await new Promise(r => setTimeout(r, 10)); // Simulate async
return { done: false, value: data[index++] };
},
};
}
}
async function consumeAsync() {
const source = new AsyncDataSource([1, 2, 3, 4, 5]);
const results = [];
for await (const item of source) {
results.push(item * 2);
}
console.log(results); // [2, 4, 6, 8, 10]
}
consumeAsync();
Tip 3: Type Tag System with Symbols
// Tag runtime type information using Symbols
const TYPE_TAG = Symbol.for("app.typeTag");
function tagged(Class) {
Class.prototype[TYPE_TAG] = Class.name;
return Class;
}
@tagged // TypeScript/Babel decorator syntax (or call tagged(class...) manually)
class User { constructor(name) { this.name = name; } }
// Manual approach:
class Product { constructor(name) { this.name = name; } }
tagged(Product);
function getTag(obj) { return obj[TYPE_TAG]; }
const u = new User("Alice");
const p = new Product("Laptop");
console.log(getTag(u)); // "User"
console.log(getTag(p)); // "Product"