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:
stringnumberbigintbooleannullundefinedsymbol(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 usenew 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
| Operation | Syntax | Description |
|---|---|---|
| Create | Symbol('desc') | Create unique symbol |
| Global | Symbol.for('key') | Get/create from registry |
| Key lookup | Symbol.keyFor(sym) | Get registry key |
| Object key | { [sym]: value } | Use as property key |
| Get symbols | Object.getOwnPropertySymbols(obj) | Get symbol keys |
| All keys | Reflect.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