JavaScriptAdvanced

JavaScript Symbols: Unique Keys and Symbol.iterator Explained

Learn JavaScript Symbols: create unique values, use them as hidden object keys, share them with Symbol.for, and build iterables with Symbol.iterator.

All JavaScript lessons

What you will learn

JavaScript has seven primitive types: string, number, boolean, null, undefined, bigint and one you may not have used yet: Symbol. A symbol is a value that is guaranteed to be unique. That sounds strange, but it solves real problems: object keys that never clash, constants that cannot be confused with each other, and the special “hooks” that make features like for...of, spread and String(obj) work. In this lesson you will learn how to create symbols, how to use them as property keys, how the global symbol registry works, and the most important built-in symbol, Symbol.iterator, which lets you build your own iterable objects.

What is a Symbol?

A symbol is created by calling Symbol(). Do not use new.

const id = Symbol();

console.log(typeof id);   // "symbol"
console.log(id);          // Symbol()

You can add an optional description (a label). It is only for debugging and does not change the symbol.

const userId = Symbol("userId");

console.log(userId);               // Symbol(userId)
console.log(userId.description);   // "userId"
console.log(userId.toString());    // "Symbol(userId)"

Every symbol is unique

Even two symbols with the same description are different:

const a = Symbol("id");
const b = Symbol("id");

console.log(a === b);   // false
console.log(a === a);   // true

This uniqueness is the whole point of symbols.

new Symbol() does not work

// const s = new Symbol("x");   // TypeError: Symbol is not a constructor
const s = Symbol("x");           // correct

Symbols and type conversion

Symbols are strict about conversion, to stop you from mixing them up with text by mistake.

const s = Symbol("tag");

console.log(String(s));      // "Symbol(tag)" (explicit conversion works)
console.log(s.toString());   // "Symbol(tag)"
console.log(Boolean(s));     // true

// console.log(s + "");      // TypeError: Cannot convert a Symbol value to a string
// console.log(`${s}`);      // TypeError
// console.log(s + 1);       // TypeError: Cannot convert a Symbol value to a number

So use String(s) or s.description when you want to show a symbol.

Symbols as object keys

Normally, object keys are text. A symbol can also be a key. Put it in square brackets (a computed key):

const id = Symbol("id");

const user = {
  name: "Riya",
  [id]: 101
};

console.log(user[id]);     // 101
console.log(user.name);    // "Riya"

To read it, you need the same symbol. You cannot use dot notation:

console.log(user.id);          // undefined (that is a text key "id", not our symbol)
console.log(user["id"]);       // undefined
console.log(user[id]);         // 101

Adding a symbol key later works the same way:

const note = Symbol("note");
const task = { title: "Learn JS" };

task[note] = "Practise every day";
console.log(task[note]);   // "Practise every day"

Symbol keys are hidden from most loops

Symbol-keyed properties are skipped by the usual ways of listing an object:

const secret = Symbol("secret");

const user = {
  name: "Riya",
  age: 22,
  [secret]: "hidden"
};

console.log(Object.keys(user));          // ["name", "age"]
console.log(Object.values(user));        // ["Riya", 22]
console.log(Object.entries(user));       // [["name", "Riya"], ["age", 22]]
console.log(JSON.stringify(user));       // {"name":"Riya","age":22}

for (const key in user) {
  console.log(key);                      // "name", "age" (no secret)
}

This makes symbols perfect for adding extra information to an object without disturbing code that loops over it or sends it to a server.

How to find symbol keys

They are not truly private. You can find them with special methods:

const secret = Symbol("secret");
const user = { name: "Riya", [secret]: "hidden" };

console.log(Object.getOwnPropertySymbols(user));   // [Symbol(secret)]
console.log(Reflect.ownKeys(user));                // ["name", Symbol(secret)]  (all keys, text and symbols)

Important: symbols give you hiding, not security. Anyone who has access to the object can find its symbol keys. For real privacy in classes, use #private fields (OOP section).

Spread and Object.assign do copy symbol keys that are enumerable:

const tag = Symbol("tag");
const original = { a: 1, [tag]: "x" };

const copy = { ...original };
console.log(copy[tag]);   // "x"

Why symbols exist: avoiding name clashes

Imagine you use a library that gives objects an id property, and you also want your own id. They would overwrite each other. With a symbol, your key can never collide with any other key:

// Code from a library or another developer
const product = { id: "lib-55", name: "Pen" };

// Your own hidden data
const myId = Symbol("myId");
product[myId] = "my-001";

console.log(product.id);       // "lib-55" (untouched)
console.log(product[myId]);    // "my-001"

The global symbol registry: Symbol.for

Normal symbols are always unique. Sometimes you want the same symbol in different files. Symbol.for(key) looks in a global registry: if a symbol with that key exists, it returns it, otherwise it creates one.

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

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

console.log(Symbol("app.user") === Symbol.for("app.user"));   // false (a normal symbol is never in the registry)

Symbol.keyFor(symbol) goes backwards: it gives the registry key.

const shared = Symbol.for("app.user");

console.log(Symbol.keyFor(shared));            // "app.user"
console.log(Symbol.keyFor(Symbol("normal")));  // undefined (not in the registry)

Tip: use a clear prefix like "myapp.something" for registry keys, so they do not clash with other code.

Symbols as constants (enum-like values)

Symbols are great as constants, because each one is unique and can never be confused with a text or a number:

const Status = Object.freeze({
  PENDING: Symbol("pending"),
  ACTIVE: Symbol("active"),
  DONE: Symbol("done")
});

function describe(status) {
  switch (status) {
    case Status.PENDING: return "Waiting to start";
    case Status.ACTIVE: return "In progress";
    case Status.DONE: return "Finished";
    default: return "Unknown status";
  }
}

console.log(describe(Status.ACTIVE));   // "In progress"
console.log(describe("active"));        // "Unknown status" (a text cannot pretend to be a status)

The small downside: symbols do not survive JSON.stringify or localStorage. For data you save or send, plain text constants are usually better.

Well-known symbols

JavaScript has built-in symbols that act as hooks. If your object has a method under one of these keys, JavaScript uses it in a special situation. They are properties of Symbol.

Symbol Where JavaScript uses it
Symbol.iterator for...of, spread [...x], destructuring, Array.from, new Map(x), new Set(x)
Symbol.asyncIterator for await...of
Symbol.toPrimitive Converting an object to a primitive (+obj, ${obj}, obj + 1)
Symbol.toStringTag The label in Object.prototype.toString.call(obj)
Symbol.hasInstance The instanceof operator
Symbol.species, Symbol.isConcatSpreadable, Symbol.match and others Deeper customisation

The most important one for you right now is Symbol.iterator.

Symbol.iterator and iterables

What is an iterable?

An iterable is any object that for...of can loop over. Arrays, strings, Map and Set are iterables. What do they have in common? They all have a method stored under the key Symbol.iterator.

const array = [10, 20, 30];

console.log(typeof array[Symbol.iterator]);   // "function"

const text = "Hi";
console.log(typeof text[Symbol.iterator]);    // "function"

const plain = { a: 1 };
console.log(typeof plain[Symbol.iterator]);   // "undefined" (plain objects are NOT iterable)

That is why for (const x of { a: 1 }) fails with TypeError: ... is not iterable.

How iteration works behind the scenes

When you write for (const x of something), JavaScript does this:

  1. It calls something[Symbol.iterator](). This returns an iterator.
  2. It calls the iterator’s next() method again and again.
  3. Each next() returns an object { value, done }.
  4. When done is true, the loop ends.

You can do this by hand:

const array = ["a", "b"];

const iterator = array[Symbol.iterator]();

console.log(iterator.next());   // { value: "a", done: false }
console.log(iterator.next());   // { value: "b", done: false }
console.log(iterator.next());   // { value: undefined, done: true }

Make your own iterable

To make your object work with for...of, give it a [Symbol.iterator]() method that returns an object with a next() method.

const countdown = {
  from: 5,

  [Symbol.iterator]() {
    let current = this.from;

    return {
      next() {
        if (current > 0) {
          return { value: current--, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

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

Once your object is iterable, all the features that use iterables work with it:

console.log([...countdown]);              // [5, 4, 3, 2, 1]
console.log(Array.from(countdown));       // [5, 4, 3, 2, 1]
console.log(Math.max(...countdown));      // 5

const [first, second] = countdown;
console.log(first, second);               // 5 4

console.log(new Set(countdown).size);     // 5

A class-based iterable: a range

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

  [Symbol.iterator]() {
    let value = this.start;
    const { end, step } = this;

    return {
      next() {
        if (value <= end) {
          const result = { value, done: false };
          value += step;
          return result;
        }
        return { value: undefined, done: true };
      }
    };
  }
}

console.log([...new Range(1, 5)]);       // [1, 2, 3, 4, 5]
console.log([...new Range(0, 10, 5)]);   // [0, 5, 10]

A shortcut with generators (preview)

Writing the next() method by hand is long. A generator function (function*) with yield does the same job in a few lines. You will learn generators later; here is a small preview:

const countdown = {
  from: 3,

  *[Symbol.iterator]() {
    for (let n = this.from; n > 0; n--) {
      yield n;
    }
  }
};

console.log([...countdown]);   // [3, 2, 1]

Iterables can be infinite and lazy

An iterator produces one value at a time, only when asked. So it can describe a sequence that never ends, and you take only what you need:

const naturals = {
  [Symbol.iterator]() {
    let n = 1;
    return { next: () => ({ value: n++, done: false }) };
  }
};

for (const n of naturals) {
  if (n > 5) break;     // stop yourself, or the loop never ends
  console.log(n);       // 1, 2, 3, 4, 5
}

Symbol.toPrimitive: control conversion

If an object has a [Symbol.toPrimitive](hint) method, JavaScript uses it when it must turn the object into a number or text. The hint is "string", "number" or "default".

const temperature = {
  celsius: 25,

  [Symbol.toPrimitive](hint) {
    if (hint === "string") return `${this.celsius}°C`;
    return this.celsius;   // "number" and "default"
  }
};

console.log(`${temperature}`);   // "25°C"       (hint: string)
console.log(+temperature);       // 25            (hint: number)
console.log(temperature + 5);    // 30            (hint: default)
console.log(temperature > 20);   // true

Symbol.toStringTag: a friendly label

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

const price = new Money();

console.log(Object.prototype.toString.call(price));   // "[object Money]"
console.log(String(price));                           // "[object Money]"

Without it, the label would be [object Object].

Symbol.hasInstance: customise instanceof

class Even {
  static [Symbol.hasInstance](value) {
    return Number.isInteger(value) && value % 2 === 0;
  }
}

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

Real-life use cases

1. Attach hidden metadata to an object

const meta = Symbol("meta");

function track(item) {
  item[meta] = { createdAt: 1000, source: "import" };
  return item;
}

const product = track({ name: "Pen", price: 10 });

console.log(Object.keys(product));       // ["name", "price"]
console.log(JSON.stringify(product));    // {"name":"Pen","price":10}  (metadata not sent)
console.log(product[meta].source);       // "import"

2. A safe way to extend objects you do not own

const visited = Symbol("visited");

function markVisited(obj) {
  obj[visited] = true;
}

const page = { url: "/home" };
markVisited(page);

console.log(page[visited]);   // true
console.log(page);            // { url: "/home", [Symbol(visited)]: true }

3. A playlist you can loop over

class Playlist {
  #songs = [];

  add(song) {
    this.#songs.push(song);
    return this;
  }

  [Symbol.iterator]() {
    let index = 0;
    const songs = this.#songs;

    return {
      next: () =>
        index < songs.length
          ? { value: songs[index++], done: false }
          : { value: undefined, done: true }
    };
  }
}

const playlist = new Playlist().add("Song A").add("Song B").add("Song C");

for (const song of playlist) {
  console.log(song);
}

console.log([...playlist].length);   // 3

The songs stay private, and the outside code still gets a natural way to loop.

4. Making a plain object iterable over its entries

const scores = {
  Riya: 90,
  Karan: 75,

  *[Symbol.iterator]() {
    for (const [name, score] of Object.entries(this)) {
      yield `${name}: ${score}`;
    }
  }
};

console.log([...scores]);   // ["Riya: 90", "Karan: 75"]

(Symbol-keyed methods are skipped by Object.entries, so the iterator method itself does not appear in the list.)

5. Unique event types that cannot be mistyped

const EVENTS = Object.freeze({
  LOGIN: Symbol("login"),
  LOGOUT: Symbol("logout")
});

const handlers = new Map();

function on(event, fn) {
  handlers.set(event, fn);
}

function emit(event, data) {
  handlers.get(event)?.(data);
}

on(EVENTS.LOGIN, (user) => console.log(`${user} logged in`));

emit(EVENTS.LOGIN, "Riya");   // "Riya logged in"
emit("login", "Riya");        // nothing happens: a text is not the event symbol

6. Shared symbol across files

// file-a.js
const key = Symbol.for("mycodenest.theme");
const store = { [key]: "dark" };

// file-b.js (a different file can get the very same symbol)
const sameKey = Symbol.for("mycodenest.theme");
console.log(store[sameKey]);   // "dark"

7. Fibonacci numbers, one at a time

const fibonacci = {
  [Symbol.iterator]() {
    let [a, b] = [0, 1];
    return {
      next() {
        const value = a;
        [a, b] = [b, a + b];
        return { value, done: false };
      }
    };
  }
};

const firstEight = [];
for (const n of fibonacci) {
  if (firstEight.length === 8) break;
  firstEight.push(n);
}

console.log(firstEight);   // [0, 1, 1, 2, 3, 5, 8, 13]

Common mistakes

  • Using new Symbol(). Symbols are created by calling Symbol() without new.
  • Reading a symbol key with dot notation like obj.id. Use obj[id] with the same symbol.
  • Creating a new Symbol("x") each time and expecting it to equal the old one. Every call makes a different symbol. Store it in a constant, or use Symbol.for.
  • Joining a symbol with text ("id: " + sym or a template literal). It throws a TypeError. Use sym.description or String(sym).
  • Expecting symbols to be private or secure. They are only hidden from normal loops. Object.getOwnPropertySymbols finds them.
  • Expecting symbol keys in JSON.stringify, Object.keys or for...in. They are skipped on purpose.
  • Saving symbols in localStorage or sending them to an API. They do not survive JSON. Use text for stored data.
  • Forgetting that plain objects are not iterable. Add [Symbol.iterator], or use Object.entries(obj) with for...of.
  • Returning a wrong shape from next(). It must return an object with value and done.
  • Creating an infinite iterable and looping without break. The loop never ends and the page freezes.
  • Using Symbol.for with a very common key like "id". Anyone can get the same symbol. Use a clear prefix.

Practice

  1. Create two symbols with the same description and show that they are not equal. Print their typeof and description.
  2. Create an object with a normal name property and a symbol property. Print Object.keys, JSON.stringify, and Object.getOwnPropertySymbols for it.
  3. Show what happens when you try "Symbol: " + Symbol("x"), and fix it with .description.
  4. Use Symbol.for("app.theme") in two places and prove that you get the same symbol. Use Symbol.keyFor to read its key.
  5. Create a Status object with three symbol constants, and write a function that returns a message for each status using switch.
  6. Loop over a string and an array with for...of, then call [Symbol.iterator]() and next() by hand on the array.
  7. Make an object weekdays with a list of days and a [Symbol.iterator] so that [...weekdays] returns the days.
  8. Write a Range class (start, end, step) that works with for...of and spread.
  9. Give an object a [Symbol.toPrimitive] method so that ${obj} gives text, and obj + 1 gives a number.
  10. Challenge: write a function take(iterable, n) that returns the first n values of any iterable (even an infinite one) as an array.

Recap

  • A Symbol is a primitive type whose values are unique. Create one with Symbol("description"), without new.
  • Two symbols are never equal, even with the same description. The description (sym.description) is just a label for debugging.
  • Symbols do not convert to text automatically. Use String(sym) or sym.description.
  • Use a symbol as an object key with [sym]. Symbol keys are skipped by Object.keys, for...in and JSON.stringify, but they can be found with Object.getOwnPropertySymbols and Reflect.ownKeys. They hide data, they do not secure it.
  • Symbols prevent key collisions, and they work well as unique constants.
  • Symbol.for(key) returns a shared symbol from the global registry, and Symbol.keyFor(sym) reads its key.
  • Well-known symbols such as Symbol.iterator, Symbol.toPrimitive, Symbol.toStringTag and Symbol.hasInstance let your objects plug into built-in JavaScript behaviour.
  • An object with a [Symbol.iterator]() method that returns an object with next() (giving { value, done }) is iterable, so it works with for...of, spread, destructuring, Array.from, Map and Set.
  • Next you will start the DOM and the Browser section, where JavaScript finally changes real web pages. First stop: the DOM introduction.