Module 6 · Modern JavaScript & Tooling Concepts ⏱ 18 min

Symbols & Well-Known Symbols

By the end of this lesson you will be able to:
  • Create unique property keys with Symbol to avoid name collisions
  • Explain why Symbols are invisible to Object.keys and JSON.stringify
  • Use well-known symbols like Symbol.toStringTag to customize built-in behavior

Every object in JavaScript is a bag of string-keyed properties. That simplicity works until two different parts of your program — or two different libraries — try to store data under the same name. One library adds .id to track a node, another adds .id for its own cache, and the second silently overwrites the first. In large codebases this name collision is not a theoretical risk; it is a regular source of subtle, hard-to-reproduce bugs.

Symbols were added to the language to solve this exact problem. A Symbol is a primitive value that is guaranteed to be unique. Even if two symbols share the same description string, they are not equal. Because they are unique, they make perfect property keys for metadata that should not collide with anything else. You can attach as many symbol-keyed properties as you like to an object, and no string-keyed code will ever trip over them.

flowchart TD
  S1["Library A sets<br/>obj.id = 1"] --> C["obj.id overwritten"]
  S2["Library B sets<br/>obj.id = 2"] --> C
  C --> B["Collision!"]
  SYM["Symbol('id')"] --> U["Unique key<br/>no collision"]
  style C fill:#b91c1c,color:#fff
  style B fill:#b91c1c,color:#fff
  style U fill:#1e7f3d,color:#fff
String keys collide when two libraries use the same name; Symbol keys stay unique.

Creating and using Symbols

You create a Symbol by calling the global Symbol() function. You may pass an optional description string for debugging — it shows up in the console and in Symbol.prototype.toString — but the description does not affect uniqueness. Two symbols created with the same description are still completely different values.

const a = Symbol('id');
const b = Symbol('id');
console.log(a === b); // false

Unlike strings, numbers, or booleans, you must not use the new keyword with Symbol. new Symbol() throws a TypeError, because Symbols are primitives, not objects. You can use a Symbol anywhere a property key is expected: in object literals with computed property syntax [sym]: value, with dot notation if you hold the symbol in a variable, or with Object.defineProperty.

Symbols are unique primitives that work as property keys. Press Run.
const id = Symbol('user_id');
const user = { name: 'Ada' };
user[id] = 42;
console.log(user[id]);
console.log(typeof id);
console.log(id.description);

Hidden but not secret

Symbols occupy an interesting middle ground in JavaScript. They are not hidden from reflection, but they are invisible to the everyday tools that iterate over properties. Object.keys, for...in loops, and JSON.stringify all skip symbol-keyed properties entirely. This makes them ideal for attaching internal state or metadata that should not leak into normal enumeration.

If you genuinely need to inspect them, Object.getOwnPropertySymbols returns an array of every symbol key on an object. Reflect.ownKeys goes further and returns both string and symbol keys together. So symbols are not a security mechanism — they are a collision-avoidance mechanism. Anyone with a reference to the object can find the symbols if they look hard enough. The point is that casual code never stumbles over them by accident.

flowchart TD
  O["Object with string + symbol keys"] --> K["Object.keys"] --> S["String keys only"]
  O --> R["Reflect.ownKeys"] --> B["Both string + symbol"]
  style K fill:#3776ab,color:#fff
  style R fill:#e0900b,color:#fff
  style B fill:#1e7f3d,color:#fff
Object.keys sees only strings; Reflect.ownKeys sees both strings and symbols.
Symbol keys are skipped by Object.keys but revealed by Reflect.ownKeys.
const secret = Symbol('secret');
const obj = { name: 'x', [secret]: 123 };
console.log(Object.keys(obj).length);
console.log(Object.getOwnPropertySymbols(obj).length);
console.log(Reflect.ownKeys(obj).length);

Well-known Symbols

JavaScript defines a set of built-in symbols called well-known symbols. They live on the Symbol constructor itself — Symbol.iterator, Symbol.toStringTag, Symbol.toPrimitive — and they act as hooks into the engine's own behavior. By defining a property with one of these symbol keys, you customize how the engine treats your object in specific contexts.

We have already met Symbol.iterator, the key that makes an object loopable. Symbol.toStringTag controls the tag you see when Object.prototype.toString is called on your object. Instead of the generic [object Object], you can make your class report [object Point] or [object Transaction]. Symbol.toPrimitive lets you control how your object converts to a number, string, or default primitive when used in arithmetic or concatenation. These are not abstract theory; libraries and frameworks use them constantly.

flowchart TD
  O["Custom object"] --> I["[Symbol.iterator]"] --> L["for...of"]
  O --> T["[Symbol.toStringTag]"] --> S["[object Point]"]
  O --> P["[Symbol.toPrimitive]"] --> C["Type coercion"]
  style O fill:#e0900b,color:#fff
  style L fill:#3776ab,color:#fff
  style S fill:#3776ab,color:#fff
  style C fill:#3776ab,color:#fff
Well-known symbols are hooks that let you customize built-in engine behavior.
Symbol.toStringTag customizes the output of Object.prototype.toString.
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  get [Symbol.toStringTag]() {
    return 'Point';
  }
}
const p = new Point(1, 2);
console.log(Object.prototype.toString.call(p));
Exercise

What does this print? typeof on a Symbol returns the primitive type name.

const s = Symbol('test');
console.log(typeof s);
console.log(s.description);
Exercise

Write createSecretBox(value) that returns an object with a string property type set to "box" and a symbol-keyed property holding the value. The symbol should have the description "contents". Then write readSecret(box, sym) that returns the value stored under that symbol key.

function createSecretBox(value) {
  // return { type: 'box', [Symbol('contents')]: value }
}

function readSecret(box, sym) {
  // return the value stored under sym
}
Exercise

This function is meant to create a symbol with the given description and return it. But it returns the description string instead of the symbol. Fix it so the tests pass.

function makeKey(desc) {
  const key = Symbol(desc);
  return desc;
}
Exercise

What does this print? JSON.stringify silently drops symbol-keyed properties.

const s = Symbol('n');
const obj = { a: 1, [s]: 2 };
console.log(JSON.stringify(obj));
Exercise

Write createPoint(x, y) that returns an object with x and y properties and a well-known symbol Symbol.toStringTag set to "Point", so that Object.prototype.toString.call returns "[object Point]".

function createPoint(x, y) {
  // return an object with x, y, and Symbol.toStringTag
}

Recap

  • Symbol(description) creates a unique primitive that is guaranteed not to collide with any other key.
  • Symbols work as property keys but are skipped by Object.keys, for...in, and JSON.stringify.
  • Object.getOwnPropertySymbols and Reflect.ownKeys let you inspect symbol keys when needed.
  • Well-known symbols such as Symbol.iterator and Symbol.toStringTag hook into built-in behavior.
  • Symbol.toStringTag customizes the output of Object.prototype.toString.call.
  • Never use new Symbol() — it throws a TypeError because symbols are primitives.
  • Do not store persistently-needed data under symbol keys, because JSON.stringify drops them.

Next you will learn to safely navigate nested objects with optional chaining and provide precise defaults with nullish coalescing.

Checkpoint quiz

Which statement about Symbol equality is true?

What happens to symbol-keyed properties when an object is passed to JSON.stringify?

Go deeper — technical resources