Skip to main content
Advertisement

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"
Advertisement