Skip to content
Codeloom
JavaScript

JavaScript Symbols and Well-Known Symbols

Master JavaScript Symbols for unique property keys, explore well-known symbols like Symbol.iterator and Symbol.toPrimitive, and learn metaprogramming patterns.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What Symbols are and why they exist
  • How well-known symbols customize built-in JavaScript behavior
  • Practical uses for Symbol.iterator, Symbol.toPrimitive, and Symbol.hasInstance

Prerequisites

  • JavaScript objects and property access
  • Basic understanding of iterators

Symbol is a primitive type introduced in ES2015. Every symbol is unique and immutable, making symbols ideal for property keys that will never collide with other keys — even if they share the same description.

Creating Symbols

const sym1 = Symbol("id");
const sym2 = Symbol("id");

console.log(sym1 === sym2); // false -- every Symbol is unique
console.log(typeof sym1);   // "symbol"

The string "id" is just a description for debugging. It does not affect uniqueness.

Symbols as property keys

const id = Symbol("id");

const user = {
  name: "Alice",
  [id]: 12345,
};

console.log(user[id]);       // 12345
console.log(user.id);        // undefined -- dot notation does not work with symbols
console.log(Object.keys(user)); // ["name"] -- symbols are not enumerable

Symbol-keyed properties are invisible to for...in, Object.keys(), and JSON.stringify(). This makes them useful for attaching metadata without interfering with normal property enumeration.

Accessing symbol properties

// Get symbol-keyed properties specifically
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]

// Or get everything
console.log(Reflect.ownKeys(user)); // ["name", Symbol(id)]

The global symbol registry

Symbol.for() creates shared symbols. If a symbol with the given key already exists in the global registry, it returns the existing one.

const s1 = Symbol.for("app.id");
const s2 = Symbol.for("app.id");

console.log(s1 === s2); // true -- same symbol from the registry

// Retrieve the key from a registered symbol
console.log(Symbol.keyFor(s1)); // "app.id"

// Regular symbols are not registered
const s3 = Symbol("app.id");
console.log(Symbol.keyFor(s3)); // undefined

Global symbols are useful when multiple parts of an application (or multiple libraries) need to agree on the same symbol.

Well-Known Symbols

JavaScript defines several built-in symbols that let you customize how objects interact with language features. These are properties of the Symbol constructor.

Symbol.iterator

Defines the default iterator for an object, enabling for...of loops and spread syntax.

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;

    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { done: true };
      },
    };
  }
}

const range = new Range(1, 5);

for (const n of range) {
  console.log(n); // 1, 2, 3, 4, 5
}

console.log([...range]); // [1, 2, 3, 4, 5]

const [first, second] = range;
console.log(first, second); // 1 2

Symbol.toPrimitive

Controls how an object is converted to a primitive value. Receives a hint of "number", "string", or "default".

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === "number") {
      return this.amount;
    }
    if (hint === "string") {
      return `${this.amount} ${this.currency}`;
    }
    // "default" hint -- used by == and +
    return this.amount;
  }
}

const price = new Money(29.99, "USD");

console.log(`Price: ${price}`);  // "Price: 29.99 USD" (string hint)
console.log(price + 10);         // 39.99 (default hint)
console.log(+price);             // 29.99 (number hint)
console.log(price > 20);         // true (number hint)

Symbol.hasInstance

Customizes the behavior of the instanceof operator.

class EvenNumber {
  static [Symbol.hasInstance](value) {
    return typeof value === "number" && value % 2 === 0;
  }
}

console.log(4 instanceof EvenNumber);   // true
console.log(7 instanceof EvenNumber);   // false
console.log("4" instanceof EvenNumber); // false

Symbol.toStringTag

Controls the string returned by Object.prototype.toString().

class Database {
  get [Symbol.toStringTag]() {
    return "Database";
  }
}

const db = new Database();
console.log(Object.prototype.toString.call(db)); // "[object Database]"
console.log(`${db}`); // Still calls toString(), which you can also override

Symbol.species

Determines the constructor used when built-in methods create derived objects. This is important when subclassing built-in types.

class TrackedArray extends Array {
  // Ensure methods like .map() and .filter() return plain Arrays
  static get [Symbol.species]() {
    return Array;
  }
}

const tracked = new TrackedArray(1, 2, 3);
const mapped = tracked.map((x) => x * 2);

console.log(mapped instanceof TrackedArray); // false
console.log(mapped instanceof Array);        // true

Without Symbol.species, mapped would be a TrackedArray, which might not be desired if the subclass has expensive construction logic.

Symbol.asyncIterator

Enables for await...of loops for asynchronous iteration.

class AsyncRange {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  [Symbol.asyncIterator]() {
    let current = this.start;
    const end = this.end;

    return {
      async next() {
        // Simulate async work
        await new Promise((r) => setTimeout(r, 100));
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { done: true };
      },
    };
  }
}

async function main() {
  for await (const n of new AsyncRange(1, 3)) {
    console.log(n); // 1, 2, 3 (with 100ms delay between each)
  }
}

main();

Practical patterns

Preventing property name collisions in libraries

// Library code
const INTERNAL_STATE = Symbol("internalState");

function enhance(obj) {
  obj[INTERNAL_STATE] = { initialized: true, version: 2 };
  return obj;
}

function isEnhanced(obj) {
  return obj[INTERNAL_STATE]?.initialized === true;
}

// User code -- no risk of collision
const myObj = { internalState: "user data" };
enhance(myObj);

console.log(myObj.internalState); // "user data" -- untouched
console.log(isEnhanced(myObj));   // true

Type branding with symbols

const Validated = Symbol("validated");

function validateEmail(email) {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    throw new Error("Invalid email");
  }
  const branded = new String(email);
  branded[Validated] = true;
  return branded;
}

function sendEmail(to) {
  if (!to[Validated]) {
    throw new Error("Email must be validated first");
  }
  // Safe to send
}

Summary

Symbols provide guaranteed-unique property keys and a mechanism for customizing JavaScript’s built-in behaviors:

  • Every Symbol() call creates a unique value, preventing property name collisions.
  • Symbol.for() creates shared symbols via a global registry.
  • Symbol-keyed properties are hidden from Object.keys(), for...in, and JSON.stringify().
  • Well-known symbols like Symbol.iterator, Symbol.toPrimitive, and Symbol.hasInstance let you control how objects interact with language operators and built-in methods.
  • Libraries use symbols to attach internal metadata to objects without risking collisions with user-defined properties.

Symbols are one of JavaScript’s most underused features. Understanding them unlocks powerful metaprogramming patterns and cleaner library design.