Asher Cohen
Back to posts

JavaScript Symbols: The Complete Guide

Deep dive into JavaScript Symbols — creation, usage patterns, well-known symbols, and practical applications

Introduction

Symbols are a primitive data type introduced in ECMAScript 2015 (ES6). They provide a way to create unique identifiers that can be used as object property keys. Understanding Symbols unlocks advanced JavaScript patterns and helps you write more robust, collision-free code.

What are Symbols?

Symbols are one of JavaScript's seven primitive types:

  • string
  • number
  • bigint
  • boolean
  • null
  • undefined
  • symbol (ES2015)

Each Symbol value is unique and immutable, making them perfect for creating hidden or special object properties.

Creating Symbols

Basic Symbol Creation

// Create a symbol
const sym1 = Symbol();

// Create a symbol with a description (for debugging)
const sym2 = Symbol('userId');
const sym3 = Symbol('userId');

// Symbols are unique, even with the same description
console.log(sym2 === sym3); // false

// Symbol description is only for debugging
console.log(sym2.toString()); // "Symbol(userId)"
console.log(sym2.description); // "userId"

Important Notes:

  • Symbol() is a function, not a constructor (don't use new Symbol())
  • The description is only for debugging, not part of the Symbol itself
  • Each Symbol() call returns a completely unique value

Symbol.for() - Global Symbol Registry

// Create or retrieve a symbol from the global registry
const sym1 = Symbol.for('userId');
const sym2 = Symbol.for('userId');

console.log(sym1 === sym2); // true (same symbol from registry)

// Get the description/key of a registered symbol
console.log(Symbol.keyFor(sym1)); // "userId"

// Non-registered symbols return undefined
const localSym = Symbol('test');
console.log(Symbol.keyFor(localSym)); // undefined

// Practical usage: shared symbols across modules
// module-a.js
export const LOG_EVENT = Symbol.for('app.log');

// module-b.js
import { LOG_EVENT } from './module-a';
// Both modules use the same symbol

Symbol.iterator and Well-Known Symbols

JavaScript has several built-in "well-known" symbols:

// Access well-known symbols
Symbol.iterator;      // Default iterator
Symbol.asyncIterator; // Async iterator
Symbol.toStringTag;   // Custom toString behavior
Symbol.toPrimitive;   // Custom type conversion
Symbol.match;         // String matching
Symbol.replace;       // String replacement
Symbol.search;        // String searching
Symbol.split;         // String splitting
Symbol.hasInstance;   // Instance checking
Symbol.isConcatSpreadable; // Array concatenation
Symbol.unscopables;   // Property exclusions in with

Using Symbols as Object Keys

Basic Usage

const sym = Symbol('id');

const user = {
  [sym]: 123,
  name: 'Alice'
};

console.log(user[sym]); // 123
console.log(user.name); // 'Alice'

// Symbols are not enumerable in for...in
for (const key in user) {
  console.log(key); // Only 'name', not the symbol
}

// Symbols are not returned by Object.keys()
console.log(Object.keys(user)); // ['name']

// Use Reflect.ownKeys() to get all keys including symbols
console.log(Reflect.ownKeys(user)); // ['name', Symbol(id)]

Multiple Symbol Properties

const id = Symbol('id');
const secret = Symbol('secret');
const metadata = Symbol('metadata');

class User {
  constructor(name) {
    this.name = name;
    this[id] = Date.now();
    this[secret] = 'hidden';
    this[metadata] = { created: new Date() };
  }
  
  getId() {
    return this[id];
  }
}

const user = new User('Bob');
console.log(user.name); // 'Bob' (public)
console.log(user.getId()); // 1234567890 (via symbol)
console.log(Object.keys(user)); // ['name'] (symbols hidden)

Symbol Properties in Classes

const _private = Symbol('private');

class Counter {
  constructor() {
    this[_private] = 0;
  }
  
  increment() {
    this[_private]++;
  }
  
  getCount() {
    return this[_private];
  }
}

const counter = new Counter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 2

// Cannot accidentally overwrite private data
counter._private = 999; // Creates a new property, doesn't affect symbol
console.log(counter.getCount()); // Still 2

Practical Use Cases

1. Hidden Object Properties

const internal = Symbol('internal');

class Cache {
  constructor() {
    this[internal] = new Map();
  }
  
  set(key, value) {
    this[internal].set(key, value);
  }
  
  get(key) {
    return this[internal].get(key);
  }
  
  // Internal data not exposed in normal iteration
  toJSON() {
    return { type: 'Cache', size: this[internal].size };
  }
}

const cache = new Cache();
cache.set('user', { name: 'Alice' });
console.log(JSON.stringify(cache)); // {"type":"Cache","size":1}

2. Avoiding Name Collisions

// Library code - might conflict with user properties
const LIBRARY_ID = Symbol('libId');

function extendObject(obj) {
  obj[LIBRARY_ID] = generateId();
  return obj;
}

// User code
const myObj = { id: 1, name: 'test' };
extendObject(myObj);

// No collision - user's 'id' and library's LIBRARY_ID are separate
console.log(myObj.id); // 1 (user's property)
console.log(myObj[LIBRARY_ID]); // unique symbol value

3. Custom Iterator Behavior

const myIterable = {
  [Symbol.iterator]() {
    let step = 0;
    return {
      next() {
        step++;
        if (step <= 3) {
          return { value: step, done: false };
        }
        return { done: true };
      }
    };
  }
};

for (const value of myIterable) {
  console.log(value); // 1, 2, 3
}

// Or use spread operator
console.log([...myIterable]); // [1, 2, 3]

4. Custom toString Behavior

const user = {
  firstName: 'John',
  lastName: 'Doe',
  [Symbol.toStringTag]: 'User'
};

console.log(Object.prototype.toString.call(user)); 
// "[object User]" (instead of "[object Object]")

// Real-world example: custom class tagging
class MyClass {
  get [Symbol.toStringTag]() {
    return 'MyClass';
  }
}

console.log(Object.prototype.toString.call(new MyClass()));
// "[object MyClass]"

5. Custom Type Conversion

const obj = {
  value: 42,
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') {
      return this.value;
    }
    if (hint === 'string') {
      return `Value: ${this.value}`;
    }
    return this.value; // default
  }
};

console.log(+obj); // 42 (number conversion)
console.log(`${obj}`); // "Value: 42" (string conversion)
console.log(obj > 40); // true (uses number conversion)

6. Custom Match Behavior (Regex)

class CustomMatcher {
  constructor(pattern) {
    this.pattern = pattern;
  }
  
  [Symbol.match](string) {
    console.log(`Custom matching: ${this.pattern} in ${string}`);
    return string.includes(this.pattern);
  }
  
  [Symbol.replace](string, replacement) {
    console.log(`Replacing ${this.pattern} with ${replacement}`);
    return string.replace(new RegExp(this.pattern, 'g'), replacement);
  }
}

const matcher = new CustomMatcher('foo');
'foobar'.match(matcher); // Custom matching: foo in foobar
'foobar'.replace(matcher, 'bar'); // Replacing foo with bar

7. Module-Level Constants

// constants.js
export const ACTION_TYPES = {
  FETCH_START: Symbol('fetch/start'),
  FETCH_SUCCESS: Symbol('fetch/success'),
  FETCH_ERROR: Symbol('fetch/error'),
  UPDATE_DATA: Symbol('update/data')
};

// reducer.js
import { ACTION_TYPES } from './constants';

function reducer(state, action) {
  switch (action.type) {
    case ACTION_TYPES.FETCH_START:
      return { ...state, loading: true };
    case ACTION_TYPES.FETCH_SUCCESS:
      return { ...state, loading: false, data: action.payload };
    case ACTION_TYPES.FETCH_ERROR:
      return { ...state, loading: false, error: action.error };
    default:
      return state;
  }
}

// Symbols prevent accidental string matching

Symbol Properties and Enumeration

Getting Symbol Properties

const sym1 = Symbol('a');
const sym2 = Symbol('b');
const sym3 = Symbol.for('c');

const obj = {
  [sym1]: 1,
  [sym2]: 2,
  [sym3]: 3,
  regular: 'value'
};

// Object.keys() - only string keys
console.log(Object.keys(obj)); // ['regular']

// Object.getOwnPropertySymbols() - only symbol keys
console.log(Object.getOwnPropertySymbols(obj)); 
// [Symbol(a), Symbol(b), Symbol.for('c')]

// Reflect.ownKeys() - all keys (strings and symbols)
console.log(Reflect.ownKeys(obj)); 
// ['regular', Symbol(a), Symbol(b), Symbol.for('c')]

// Object.getOwnPropertyDescriptors() - all properties with descriptors
const descriptors = Object.getOwnPropertyDescriptors(obj);
console.log(Object.keys(descriptors)); // ['regular']
console.log(Object.getOwnPropertySymbols(descriptors)); 
// [Symbol(a), Symbol(b), Symbol.for('c')]

Symbol Properties are Not Enumerable

const sym = Symbol('hidden');
const obj = {
  [sym]: 'secret',
  visible: 'public'
};

// for...in loop skips symbols
for (const key in obj) {
  console.log(key); // Only 'visible'
}

// Object.keys() skips symbols
console.log(Object.keys(obj)); // ['visible']

// Object.assign() skips symbols
const copy = Object.assign({}, obj);
console.log(copy[sym]); // undefined (not copied)

// Spread operator skips symbols
const spread = { ...obj };
console.log(spread[sym]); // undefined (not spread)

Making Symbols Enumerable

const sym = Symbol('enumerable');
const obj = {};

// Define symbol property as enumerable
Object.defineProperty(obj, sym, {
  value: 'visible',
  enumerable: true,
  writable: true,
  configurable: true
});

// Now it appears in for...in
for (const key in obj) {
  console.log(key); // Symbol(enumerable)
}

// And in Object.keys()... wait, no it doesn't!
// Object.keys() only returns string keys
console.log(Object.keys(obj)); // []

// But it does appear in Object.getOwnPropertyNames()... no!
// That also only returns string keys
console.log(Object.getOwnPropertyNames(obj)); // []

// Use Reflect.ownKeys() to see it
console.log(Reflect.ownKeys(obj)); // [Symbol(enumerable)]

Symbol Wrappers and Type Conversion

Symbol Wrapper Object

const sym = Symbol('test');

// Primitive symbol
console.log(typeof sym); // "symbol"

// Wrapper object (avoid this)
const symObj = Object(sym);
console.log(typeof symObj); // "object"
console.log(symObj instanceof Symbol); // true

// Don't use new Symbol() - throws error
// const bad = new Symbol(); // TypeError

Type Conversion

const sym = Symbol('test');

// Symbols cannot be implicitly converted to strings
// console.log('Value: ' + sym); // TypeError

// Explicit conversion required
console.log('Value: ' + sym.toString()); // "Value: Symbol(test)"
console.log(`Value: ${sym.toString()}`); // "Value: Symbol(test)"

// Symbol description is not the symbol itself
console.log(sym.description); // "test" (string)
console.log(typeof sym.description); // "string"

Well-Known Symbols Deep Dive

Symbol.iterator

// Default iterator for an object
const iterable = {
  [Symbol.iterator]() {
    let step = 0;
    return {
      next() {
        if (step < 3) {
          return { value: step++, done: false };
        }
        return { done: true };
      }
    };
  }
};

console.log([...iterable]); // [0, 1, 2]

// Built-in iterables
console.log([][Symbol.iterator]); // Function
console.log(''[Symbol.iterator]); // Function
console.log((function*() {})()[Symbol.iterator]); // Function

Symbol.asyncIterator

const asyncIterable = {
  [Symbol.asyncIterator]() {
    let count = 0;
    return {
      async next() {
        if (count < 3) {
          await new Promise(resolve => setTimeout(resolve, 100));
          return { value: count++, done: false };
        }
        return { done: true };
      }
    };
  }
};

(async () => {
  for await (const value of asyncIterable) {
    console.log(value); // 0, 1, 2 (with 100ms delays)
  }
})();

Symbol.hasInstance

class MyArray {
  static [Symbol.hasInstance](instance) {
    return Array.isArray(instance);
  }
}

console.log([] instanceof MyArray); // true
console.log({} instanceof MyArray); // false

// Customize instanceof behavior
class EvenNumber {
  [Symbol.hasInstance](instance) {
    return Number.isInteger(instance) && instance % 2 === 0;
  }
}

console.log(4 instanceof EvenNumber); // true
console.log(3 instanceof EvenNumber); // false

Symbol.isConcatSpreadable

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];

// Default: arrays are spread
console.log([].concat(arr1, arr2)); // [1, 2, 3, 4, 5, 6]

// Prevent spreading
arr1[Symbol.isConcatSpreadable] = false;
console.log([].concat(arr1, arr2)); // [[1, 2, 3], 4, 5, 6]

// Force spreading on array-like objects
const obj = {
  length: 2,
  0: 'a',
  1: 'b',
  [Symbol.isConcatSpreadable]: true
};
console.log([].concat(obj, 'c')); // ['a', 'b', 'c']

Symbol.toPrimitive

const obj = {
  value: 100,
  [Symbol.toPrimitive](hint) {
    console.log(`Hint: ${hint}`);
    
    if (hint === 'number') {
      return this.value;
    }
    if (hint === 'string') {
      return `Value: ${this.value}`;
    }
    // default hint
    return this.value;
  }
};

// Number context
console.log(+obj); // Hint: number, 100

// String context
console.log(String(obj)); // Hint: string, "Value: 100"

// Default context
console.log(obj == 100); // Hint: default, true
console.log(obj + 50); // Hint: default, 150

Common Pitfalls

1. Forgetting to Use Bracket Notation

const sym = Symbol('id');

// ❌ Wrong - creates a string key 'Symbol(id)'
const obj1 = {
  sym: 123
};

// ✅ Correct - uses symbol as key
const obj2 = {
  [sym]: 123
};

console.log(obj1[sym]); // undefined
console.log(obj2[sym]); // 123

2. Trying to Convert Symbol to String Implicitly

const sym = Symbol('test');

// ❌ TypeError
// console.log('ID: ' + sym);

// ✅ Explicit conversion
console.log('ID: ' + sym.toString());
console.log(`ID: ${sym.toString()}`);

3. Using Symbol as Constructor

// ❌ TypeError: Symbol is not a constructor
// const sym = new Symbol('test');

// ✅ Correct
const sym = Symbol('test');

4. Expecting Symbols in JSON

const sym = Symbol('id');
const obj = {
  [sym]: 123,
  name: 'test'
};

// Symbols are ignored in JSON
console.log(JSON.stringify(obj)); // {"name":"test"}

// Workaround: define toJSON method
const objWithToJSON = {
  [sym]: 123,
  name: 'test',
  toJSON() {
    return {
      ...this,
      id: this[sym]
    };
  }
};

console.log(JSON.stringify(objWithToJSON)); // {"name":"test","id":123}

Performance Considerations

// Symbols vs Strings for property keys

// String keys - fast lookup, but potential collisions
const obj1 = {};
obj1['id'] = 1;

// Symbol keys - guaranteed unique, slightly slower
const sym = Symbol('id');
const obj2 = {};
obj2[sym] = 1;

// For most use cases, performance difference is negligible
// Choose based on requirements:
// - Use strings for public APIs, JSON serialization
// - Use symbols for private/internal properties, avoiding collisions

Summary Table

OperationSyntaxDescription
CreateSymbol('desc')Create unique symbol
GlobalSymbol.for('key')Get/create from registry
Key lookupSymbol.keyFor(sym)Get registry key
Object key{ [sym]: value }Use as property key
Get symbolsObject.getOwnPropertySymbols(obj)Get symbol keys
All keysReflect.ownKeys(obj)Get all keys
Iterator[Symbol.iterator]Define iteration
Type conversion[Symbol.toPrimitive]Custom conversion
Instance check[Symbol.hasInstance]Custom instanceof

Conclusion

Symbols are a powerful feature that enable:

  • Unique identifiers - No naming collisions
  • Hidden properties - Not exposed in normal iteration
  • Custom behavior - Override built-in operations
  • Metaprogramming - Advanced object manipulation

Use Symbols when you need:

  • Private/internal object properties
  • Library constants that won't collide with user code
  • Custom iteration or type conversion behavior
  • Metaprogramming capabilities

Avoid Symbols when:

  • You need JSON serialization
  • You need properties to be enumerable
  • Simple string keys suffice

Symbols unlock advanced JavaScript patterns while keeping your code clean and collision-free.

#javascript #symbols #es6 #advanced #reference #web-development