JavaScript Symbols: Unique Identifiers

Symbols are a primitive data type introduced in ES6 that guarantee uniqueness. Every symbol you create is distinct from every other symbol, even if they have identical descriptions. This makes them...

Key Insights

  • Symbols are guaranteed-unique primitive values that serve as collision-proof object property keys, solving the problem of accidental property overwrites when extending objects or integrating third-party code.
  • The global symbol registry (Symbol.for()) enables cross-realm symbol sharing, while well-known symbols like Symbol.iterator let you customize fundamental JavaScript language behaviors.
  • Symbols aren’t truly private—they’re discoverable via Object.getOwnPropertySymbols()—but they’re invisible to normal enumeration, making them ideal for metadata and internal properties that shouldn’t interfere with standard object operations.

What Are Symbols and Why They Matter

Symbols are a primitive data type introduced in ES6 that guarantee uniqueness. Every symbol you create is distinct from every other symbol, even if they have identical descriptions. This makes them perfect for creating property keys that will never conflict with other properties, whether from your own code, libraries, or future language additions.

Unlike strings, which are the traditional way to name object properties, symbols can’t accidentally collide. Consider a scenario where you’re extending an object from a third-party library—if you add a property called id and the library later adds its own id property, you have a collision. Symbols eliminate this problem entirely.

// String keys can collide
const obj1 = {};
obj1.id = 'my-id';
obj1.id = 'library-id'; // Overwrites!

// Symbols are always unique
const myId = Symbol('id');
const libraryId = Symbol('id');

const obj2 = {};
obj2[myId] = 'my-id';
obj2[libraryId] = 'library-id';

console.log(obj2[myId]);        // 'my-id'
console.log(obj2[libraryId]);   // 'library-id'
console.log(myId === libraryId); // false

Creating and Using Symbols

Creating a symbol is straightforward—call Symbol() as a function (not with new, as symbols are primitives). You can optionally provide a description string for debugging purposes, but this description doesn’t affect uniqueness.

const sym1 = Symbol();
const sym2 = Symbol('mySymbol');
const sym3 = Symbol('mySymbol');

console.log(sym2 === sym3); // false - descriptions don't matter for uniqueness
console.log(sym2.description); // 'mySymbol'

To use symbols as object property keys, you must use bracket notation. Dot notation won’t work because the property name would be treated as a string literal.

const SECRET_KEY = Symbol('secret');

const user = {
  name: 'Alice',
  email: 'alice@example.com',
  [SECRET_KEY]: 'sensitive-data'
};

console.log(user[SECRET_KEY]); // 'sensitive-data'
console.log(user.SECRET_KEY);  // undefined - wrong!

Global Symbol Registry

While regular symbols are always unique, sometimes you need to share symbols across different parts of your application or even across realms (like iframes). The global symbol registry provides this capability through Symbol.for() and Symbol.keyFor().

Symbol.for(key) searches the global registry for a symbol with the given key. If found, it returns that symbol; otherwise, it creates a new one and registers it.

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

// In module B (completely different scope)
const sameSymbol = Symbol.for('app.config');

console.log(sharedSymbol === sameSymbol); // true

// Retrieve the key for a global symbol
console.log(Symbol.keyFor(sharedSymbol)); // 'app.config'

// Local symbols aren't in the registry
const localSymbol = Symbol('local');
console.log(Symbol.keyFor(localSymbol)); // undefined

Use global symbols when you need coordination across modules or libraries. Use local symbols when you want guaranteed uniqueness within a specific scope.

Well-Known Symbols

JavaScript defines several built-in symbols that customize language behavior. These “well-known symbols” are accessible as static properties on the Symbol constructor and enable powerful meta-programming patterns.

The most commonly used is Symbol.iterator, which defines how an object behaves in for...of loops and spread operations:

const range = {
  start: 1,
  end: 5,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { done: true };
      }
    };
  }
};

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

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

Symbol.toStringTag customizes the string returned by Object.prototype.toString():

class CustomCollection {
  [Symbol.toStringTag] = 'CustomCollection';
}

const collection = new CustomCollection();
console.log(Object.prototype.toString.call(collection)); 
// '[object CustomCollection]'

Other well-known symbols include Symbol.hasInstance (customize instanceof), Symbol.toPrimitive (control type coercion), and Symbol.species (control derived object creation).

Practical Applications

Symbols excel at creating pseudo-private properties. While not truly private, symbol properties don’t appear in for...in loops, Object.keys(), or JSON.stringify(), making them ideal for internal state.

const _internal = Symbol('internal');

class Counter {
  constructor() {
    this[_internal] = { count: 0 };
  }
  
  increment() {
    this[_internal].count++;
  }
  
  getCount() {
    return this[_internal].count;
  }
}

const counter = new Counter();
counter.increment();
console.log(counter.getCount()); // 1
console.log(Object.keys(counter)); // [] - symbol property hidden

Symbols also prevent naming collisions when extending objects you don’t control:

const METADATA = Symbol('metadata');

function addMetadata(obj, data) {
  obj[METADATA] = data;
  return obj;
}

const externalObject = { id: 1, name: 'Item' };
addMetadata(externalObject, { createdAt: Date.now() });

// No risk of overwriting existing properties
console.log(externalObject.id); // 1
console.log(externalObject[METADATA]); // { createdAt: ... }

Symbols vs Other Approaches

WeakMaps are another option for storing private data, with different tradeoffs:

// WeakMap approach - truly private
const privateData = new WeakMap();

class User {
  constructor(name) {
    privateData.set(this, { secret: 'password' });
    this.name = name;
  }
  
  getSecret() {
    return privateData.get(this).secret;
  }
}

// Symbol approach - discoverable but hidden
const _secret = Symbol('secret');

class UserWithSymbol {
  constructor(name) {
    this[_secret] = 'password';
    this.name = name;
  }
  
  getSecret() {
    return this[_secret];
  }
}

const user1 = new User('Alice');
const user2 = new UserWithSymbol('Bob');

// WeakMap data is inaccessible
console.log(Object.getOwnPropertyNames(user1)); // ['name']
console.log(Object.getOwnPropertySymbols(user1)); // []

// Symbol properties are discoverable
console.log(Object.getOwnPropertySymbols(user2)); // [Symbol(secret)]
console.log(user2[Object.getOwnPropertySymbols(user2)[0]]); // 'password'

WeakMaps provide true privacy but require external storage. Symbols keep data on the object but remain discoverable through reflection.

Best Practices and Common Pitfalls

Symbols have enumeration quirks that can surprise developers. They’re invisible to most standard operations but discoverable through specific reflection methods:

const visible = Symbol('visible');
const obj = {
  regular: 'property',
  [visible]: 'symbol property'
};

console.log(Object.keys(obj)); // ['regular']
console.log(Object.getOwnPropertyNames(obj)); // ['regular']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(visible)]

// Reflect.ownKeys() gets everything
console.log(Reflect.ownKeys(obj)); 
// ['regular', Symbol(visible)]

JSON serialization completely ignores symbol properties:

const data = Symbol('data');
const obj = {
  name: 'Test',
  [data]: 'important'
};

console.log(JSON.stringify(obj)); // '{"name":"Test"}'
// Symbol property silently dropped!

Use symbols when you need:

  • Collision-proof property keys for mixins or extensions
  • Hidden properties that shouldn’t appear in normal enumeration
  • Custom protocol implementations (iterators, etc.)

Avoid symbols when you need:

  • True privacy (use WeakMaps or private class fields)
  • JSON serialization of all properties
  • Simple constant values (regular frozen objects work fine)

Symbols are a specialized tool. They’re not replacements for private class fields (#field) or a general-purpose way to hide data. Instead, they’re best for metadata, protocols, and safe object extension where you need property keys that won’t conflict with existing or future properties. Understanding when symbols add value versus when simpler approaches suffice is key to using them effectively.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.