· tips  · 6 min read

JavaScript Symbols: The Hidden Gems of ES6 You Should Be Using

Learn how ES6 Symbols can give you collision-resistant constants, pseudo-private properties, and powerful object-oriented hooks like custom iteration and instanceof behavior. Practical patterns, examples, and caveats included.

Learn how ES6 Symbols can give you collision-resistant constants, pseudo-private properties, and powerful object-oriented hooks like custom iteration and instanceof behavior. Practical patterns, examples, and caveats included.

Introduction - what you’ll be able to do

By the end of this article you’ll be able to: create collision‑free constants, hide internal properties from normal enumeration, and build richer object-oriented designs with custom iteration, conversion, and instanceof semantics - using a tiny, elegant JavaScript primitive: Symbol.

Symbols are small. Their impact is big.

What is a Symbol? A quick, intuitive tour

A Symbol is a unique, immutable primitive introduced in ES6. Each call to Symbol() returns a new, distinct value - even when the optional description string is the same.

Why use them? Two immediate wins:

  • Uniqueness: symbol values never collide by accident. Great for keys.
  • Built-in hooks: JavaScript recognizes several “well‑known” symbols (e.g. Symbol.iterator) that let you plug into language features.

Want the spec or a reference? See MDN’s Symbol docs for a concise reference and examples: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol

Basic examples

const a = Symbol('id');
const b = Symbol('id');
console.log(a === b); // false - both have same description but are distinct

const obj = {};
obj[a] = 123;
console.log(Object.keys(obj)); // [] - symbol keys don't show up in enumerable string-key lists
console.log(Object.getOwnPropertySymbols(obj)); // [ Symbol(id) ]

Symbols vs strings for object keys

Strings are fine often. But in libraries, frameworks, or shared spaces you risk name collisions. Symbols give you a private-ish key that won’t be found by for...in or Object.keys and won’t clash with a string key.

Use case 1 - Unique constants (safer enums and action types)

Common pattern: you need constants that won’t collide when mixing modules or plugins.

Example: action types for a state manager

// actions.js
export const ADD_ITEM = Symbol('ADD_ITEM');
export const REMOVE_ITEM = Symbol('REMOVE_ITEM');

// reducer.js
import { ADD_ITEM } from './actions';

function reducer(state, action) {
  switch (action.type) {
    case ADD_ITEM:
      // safe: no other module can accidentally dispatch a string that equals this
      return [...state, action.payload];
    // ...
  }
}

When to use Symbol.for instead

Symbol() always creates a new unique symbol. If you need a shared symbol across different execution contexts (or different modules that need to compare by a common key), use the global symbol registry with Symbol.for('my.key') and retrieve it with Symbol.for('my.key') later. Symbol.keyFor returns the key for a global symbol.

Use Symbol.for for global plugin keys or integration points. Use Symbol() for private library internals.

Use case 2 - Pseudo-private properties

Symbols are a tidy compromise between full privacy and simple object properties. They reduce accidental collisions and keep internals out of normal iteration.

Example: a class with a symbol-backed internal property

const _state = Symbol('internal state');

class Counter {
  constructor() {
    this[_state] = { count: 0 };
  }

  increment() {
    this[_state].count++;
  }
  get value() {
    return this[_state].count;
  }
}

const c = new Counter();
console.log(c.value); // 0
console.log(Object.keys(c)); // [] - the internal state is hidden from normal inspection
console.log(Object.getOwnPropertySymbols(c)); // [ Symbol(internal state) ]

Important caveat: symbol properties are not truly private. Any code can call Object.getOwnPropertySymbols() and read them. If you need stronger privacy per instance, prefer WeakMap or the #private class fields (stage 3+ / modern JS) which are truly private.

// Alternative: WeakMap for real privacy per instance
const priv = new WeakMap();
class SecretCounter {
  constructor() {
    priv.set(this, { count: 0 });
  }
  increment() {
    priv.get(this).count++;
  }
  get value() {
    return priv.get(this).count;
  }
}

Use case 3 - Enhancing object-oriented design with well-known symbols

This is where Symbols really shine. Several well-known symbols let you implement language-level protocols.

  • Symbol.iterator: make objects iterable
  • Symbol.toPrimitive: control conversion to primitive (number/string)
  • Symbol.toStringTag: customize Object.prototype.toString.call(obj)
  • Symbol.hasInstance: customize instanceof behavior
  • Symbol.asyncIterator: control async iteration

Examples

  1. Make a class iterable with Symbol.iterator
class Range {
  constructor(from, to) {
    this.from = from;
    this.to = to;
  }
  [Symbol.iterator]() {
    let current = this.from;
    const end = this.to;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        }
        return { done: true };
      },
    };
  }
}

for (const n of new Range(1, 3)) console.log(n); // 1 2 3
  1. Customize instanceof with Symbol.hasInstance
class EvenNumber {
  static [Symbol.hasInstance](value) {
    return Number.isInteger(value) && value % 2 === 0;
  }
}

console.log(2 instanceof EvenNumber); // true
console.log(3 instanceof EvenNumber); // false
  1. Control primitive conversion with Symbol.toPrimitive
class Money {
  constructor(amount) {
    this.amount = amount;
  }
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `$${this.amount.toFixed(2)}`;
    return this.amount;
  }
}

const m = new Money(9.95);
console.log(+m + 0.05); // 10.0  (number conversion)
console.log(String(m)); // "$9.95" (string conversion)

These hooks help you build objects that behave idiomatically with language features.

Use case 4 - Designing plugin and API extension points

Symbols can be used as extension points that avoid name collisions. Libraries often expose a well-known Symbol (or document a symbol key) that plugins can use to register capabilities.

Example: plugin registry pattern

// core.js
const PLUGIN_KEY = Symbol.for('myapp.plugin');

function registerPlugin(target, plugin) {
  const arr = (target[PLUGIN_KEY] ||= []);
  arr.push(plugin);
}

function runPlugins(target, ctx) {
  for (const p of target[PLUGIN_KEY] || []) p(ctx);
}

Because Symbol.for('myapp.plugin') is global, multiple modules can reference the same registry key reliably.

Integration notes and gotchas

  • JSON serialization: JSON.stringify() ignores symbol‑keyed properties. If you rely on symbol properties for persistence, you’ll need a custom serializer.
  • Enumeration: for...in, Object.keys, and Object.getOwnPropertyNames do not list symbol properties. Use Object.getOwnPropertySymbols() or Reflect.ownKeys() to inspect them.
  • Copy behavior: Object.assign() copies enumerable own symbol properties. The object spread operator also copies enumerable symbol properties (ES2018+). Be intentional when copying objects that contain symbols.
  • Not a security boundary: symbol keys are discoverable through reflection APIs; they provide encapsulation by convention, not true secrecy.

References

Quick cheat sheet

  • Use Symbol() when you want local, guaranteed-unique keys.
  • Use Symbol.for(key) when you need a shared, global symbol (plugin keys, integration points).
  • Use symbol-backed keys to hide internals from normal iteration and to avoid name collisions.
  • Use Object.getOwnPropertySymbols() or Reflect.ownKeys() when you do need to inspect symbol properties.
  • For true per-instance privacy use WeakMap or #private fields.

When should you avoid Symbols?

  • If you need values to be serialized to JSON. Symbols are ignored by JSON.stringify().
  • If you need a true security boundary. Symbols are discoverable and readable via reflection.
  • If the API users must easily access property names via string keys - symbols intentionally hide keys.

Conclusion - the practical power of symbols

Symbols are a small, expressive primitive that solve real problems: name collisions, cleaner APIs, and protocol hooks that integrate with the language itself. Use them for internal keys, robust constants, and to implement neat language-level behaviors like iteration and custom instanceof logic.

They are not a hammer for every problem. But used sparingly and intentionally, symbols help you design clearer, more resilient APIs and object models. Design your boundaries with them. Make your APIs harder to shoot yourself in the foot. That’s the real, practical power of JavaScript Symbols.

Back to Blog

Related Posts

View All Posts »
Conditional Rendering Magic

Conditional Rendering Magic

Learn how to render UI conditionally in a single line using ternary operators. Real-world examples in React, Vue, and plain JavaScript-with tips to keep one-liners readable and maintainable.