Symbols in JavaScript?

9 min read

Most JavaScript developers have never used "Symbols", not because symbols useless but because their use cases are not obvious until you know them. In this post, I'll try to help you understand:

  • What the heck are Symbols are and why they exist
  • How to create and use them
  • Some real world use cases where Symbols shine
  • When NOT to use Symbols

Let's git init.


What is a Symbol?

A Symbol is a primitive value that is guaranteed to be unique. That's it. That's the core idea.

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

console.log(sym1 === sym2); // false, always

Even if you pass the same description string, two Symbols are never equal:

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

console.log(a === b); // false
console.log(typeof a); // "symbol"

The description ('id') is just a label for debugging. It has no effect on identity.


Why do Symbols exist?

Before Symbols, there was no way to guarantee a property key wouldn't collide with another. Using a string key like "id" on an object means any other piece of code could accidentally overwrite it by using the same key.

Symbols solve this by being inherently unique keys. No two independently created Symbols will ever be the same, which means you can attach data to objects without worrying about name collisions.

Let's see where this actually matters.


Use Case #1: Avoiding Property Name Collisions

Imagine you're working with user objects that come from different parts of your app — maybe one module sets an id, and another module also wants to track its own id on the same object.

The problem with string keys:

// Module A
function addTrackingId(user) {
  user.id = 'tracking-abc-123';
}

// Module B
function addDatabaseId(user) {
  user.id = 'db-user-42';
}

const user = { name: 'John' };
addTrackingId(user);
addDatabaseId(user);

console.log(user.id); // 'db-user-42': the tracking id is gone :(

Module B silently overwrote Module A's value without any error or warning.

The fix with Symbols:

// Module A
const trackingId = Symbol('trackingId');

function addTrackingId(user) {
  user[trackingId] = 'tracking-abc-123';
}

// Module B
const databaseId = Symbol('databaseId');

function addDatabaseId(user) {
  user[databaseId] = 'db-user-42';
}

const user = { name: 'John' };
addTrackingId(user);
addDatabaseId(user);

console.log(user[trackingId]); // 'tracking-abc-123'
console.log(user[databaseId]); // 'db-user-42'

This pattern is so useful in library or plugin code where you're decorating objects you don't own.


Use Case #2: "Private-ish" Object Properties

Symbol keyed properties don't show up in most common ways of inspecting objects:

const _secret = Symbol('secret');

const config = {
  apiUrl: 'http://localhost:8000/api',
  timeout: 5000,
  [_secret]: 'my-api-key-12345'
};

console.log(Object.keys(config)); // ['apiUrl', 'timeout']
console.log(JSON.stringify(config)); // {"apiUrl":"http://localhost:8000/api","timeout":5000}

for (const key in config) {
  console.log(key); // 'apiUrl', 'timeout'
}

The Symbol-keyed property is invisible to Object.keys(), for...in, and JSON.stringify(). This makes Symbols great for storing metadata on objects that you don't want leaking to consumers or getting accidentally serialized.

The caveat

Symbols are not truly private. If someone really wants to find them, they can:

console.log(Object.getOwnPropertySymbols(config)); // [Symbol(secret)]
console.log(config[Object.getOwnPropertySymbols(config)[0]]); // 'my-api-key-12345'

So think of it as "hidden from casual access" rather than "actually private". If you need actual privacy, use private class fields (#), which we'll cover at the end.


Use Case #3: Built-in Symbols

JavaScript has a set of built-in Symbols that let you customize how your objects behave with language-level operations.

Symbol.iterator — Make anything iterable

Have you ever wondered how for...of knows how to loop over an array but throws an error on a plain object? It's because arrays have a Symbol.iterator method, and plain objects don't.

You can add one to any object:

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 num of range) {
  console.log(num); // 1, 2, 3, 4, 5
}

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

// And destructuring
const [first, second] = new Range(10, 20);
console.log(first, second); // 10 11

By defining [Symbol.iterator](), your object now works with for...of, the spread operator, destructuring, Array.from(), and any other construct that expects an iterable. You're essentially teaching JavaScript how to iterate over your custom data structure.

Symbol.toPrimitive — Control type conversion

When JS needs to convert your object to a primitive (like when you use + or put it in a template literal), it calls Symbol.toPrimitive if it exists:

class Currency {
  constructor(amount, code) {
    this.amount = amount;
    this.code = code;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === 'number') {
      return this.amount;
    }
    if (hint === 'string') {
      return `${this.amount} ${this.code}`;
    }
    // default hint
    return this.amount;
  }
}

const price = new Currency(49.99, 'USD');

console.log(`Total: ${price}`); // "Total: 49.99 USD" (string hint)
console.log(price + 10); // 59.99 (number hint)
console.log(price > 40); // true (number hint)

Without Symbol.toPrimitive, you'd get Total: [object Object]. With it, your object knows exactly how to represent itself depending on the context.

Other well-known Symbols worth knowing:

  • Symbol.hasInstance — Customizes the behavior of instanceof
  • Symbol.toStringTag — Customizes what Object.prototype.toString.call(obj) returns
  • Symbol.species — Controls which constructor is used when creating derived objects (e.g., when calling .map() on a subclass of Array)

Use Case #4: Symbols as Enum like constants

In many languages, you have enum types for representing a fixed set of values. JavaScript doesn't have native enums (TypeScript does, but that's a different story). Using strings for this is not ideal:

function handleStatus(status) {
  if (status === 'pendng') {
    // typo! No error is thrown.
  }
}

handleStatus('pending');

With Symbols, each value is unique and cannot be accidentally recreated:

const Status = Object.freeze({
  PENDING: Symbol('PENDING'),
  ACTIVE: Symbol('ACTIVE'),
  CLOSED: Symbol('CLOSED')
});

function handleStatus(status) {
  switch (status) {
    case Status.PENDING:
      console.log('Waiting to start...');
      break;
    case Status.ACTIVE:
      console.log('In progress!');
      break;
    case Status.CLOSED:
      console.log('Done.');
      break;
    default:
      throw new Error(`Unknown status: ${String(status)}`);
  }
}

handleStatus(Status.ACTIVE); // "In progress!"
handleStatus('ACTIVE'); // throws Error

Because Symbol('ACTIVE') !== Symbol('ACTIVE'), the only way to pass a valid status is by using the actual Status.ACTIVE reference. This prevents typos and makes invalid states unrepresentable. You're essentially getting type safety without TypeScript.


Symbol.for() — The Global Symbol Registry

Sometimes you actually want the same Symbol across different parts of your app, or even across different iframes. That's what Symbol.for() is for:

// In module A
const userId = Symbol.for('app.userId');

// In module B (even a completely different file)
const sameUserId = Symbol.for('app.userId');

console.log(userId === sameUserId); // true!

Symbol.for('key') checks a global registry. If a Symbol with that key already exists, it returns it. Otherwise, it creates a new one and registers it.

You can also look up the key for a registered Symbol:

const sym = Symbol.for('app.userId');
console.log(Symbol.keyFor(sym)); // 'app.userId'

const localSym = Symbol('local');
console.log(Symbol.keyFor(localSym)); // undefined: not registered

When to use Symbol.for() vs Symbol():

  • Use Symbol() when you want a guaranteed unique key (most of the time you'll need this)
  • Use Symbol.for() when you need to share a Symbol across module boundaries, iframes, or different execution contexts

Symbols vs Private Class Fields (#)

JavaScript has the support for true private fields:

class User {
  #secret; // truly private, cannot be accessed from outside

  constructor(name, secret) {
    this.name = name;
    this.#secret = secret;
  }

  checkSecret(input) {
    return input === this.#secret;
  }
}

const user = new User('John', 'secret123');
console.log(user.name); // 'John'
console.log(user.#secret); // SyntaxError!

So when should you use which?

SymbolPrivate Field (#)
AccessHidden but discoverableTruly inaccessible
WhereAny objectOnly in classes
SerializationSkipped by JSON.stringifySkipped by JSON.stringify
Best forMetadata on objects you don't own, collision free keysInternal class state

Rule of thumb: Use # for class internals. Use Symbols when you need collision free keys on objects you don't control, or when you want to leverage the built-in Symbols.


When NOT to Use Symbols

You should skip them when:

  • You're just building a regular app — String keys are fine when you control the entire codebase.
  • You need to serialize data — Symbols are invisible to JSON.stringify(). If your data needs to go over the network or into a database.
  • You want true privacy — Use #privateField instead. Symbols are "hidden" but not "private".
  • A simple const would work — Don't use Symbol('STATUS_ACTIVE') when a plain const STATUS_ACTIVE = 'active' does the job.

TL;DR

Use CaseWhy Symbols Work
Collision free property keysEvery Symbol is unique — no two are the same
Hidden object metadataInvisible to for...in, Object.keys(), JSON.stringify()
Custom iteration (Symbol.iterator)Hook into for...of, spread, destructuring
Custom type conversion (Symbol.toPrimitive)Control how your object converts to string/number
Enum like constantsCan't be accidentally recreated or faked with strings
Cross module shared keys (Symbol.for)Global registry ensures same Symbol across boundaries

That is all i've for you about symbols. The next time you're building a library or plugin, decorating 3rd party objects, or making your custom data structure iterable, you'll know exactly which tool to reach for in javascript.

Peace out!

Resources