Debugging Microservices & Distributed Systems
8 min read
advanced

Exploring JavaScript Symbols

Deep dive into JavaScript Symbols - what they are, why they matter, and how to use them effectively

I remember the first time I encountered Symbols in JavaScript. It was 2015, and like many developers, I thought, “Great, another primitive type to worry about.”

But as I’ve grown in my career, I’ve come to appreciate these quirky little primitives. They solve some interesting problems in ways that strings and numbers just can’t match.

Symbols stand apart from other JavaScript primitives because they’re guaranteed to be unique.

When you create a Symbol with Symbol('description'), you’re getting something that will never equal any other Symbol, even one created with the same description. This uniqueness is what makes them powerful for specific use cases.

JavaScript
const symbol1 = Symbol('description');
const symbol2 = Symbol('description');
console.log(symbol1 === symbol2); // false

The real power of Symbols emerges when working with objects. Unlike strings or numbers, Symbols can be used as property keys without any risk of colliding with existing properties. This makes them invaluable for adding functionality to objects without interfering with existing code.

JavaScript
const metadata = Symbol('elementMetadata');
function attachMetadata(element, data) {
element[metadata] = data;
return element;
}
const div = document.createElement('div');
const divWithMetadata = attachMetadata(div, { lastUpdated: Date.now() });
console.log(divWithMetadata[metadata]); // { lastUpdated: 1684244400000 }

When you use a Symbol as a property key, it won’t show up in Object.keys() or normal for...in loops.

JavaScript
const nameKey = Symbol('name');
const person = {
[nameKey]: 'Alex',
city: 'London'
};
// Regular enumeration won't show Symbol properties
console.log(Object.keys(person)); // ['city']
console.log(Object.entries(person)); // [['city', 'London']]
for (let key in person) {
console.log(key); // Only logs: 'city'
}
// But we can still access Symbol properties
console.log(Object.getOwnPropertySymbols(person)); // [Symbol(name)]
console.log(person[nameKey]); // 'Alex'

You can still access these properties through Object.getOwnPropertySymbols(), but it requires intentional effort. This creates a natural separation between an object’s public interface and its internal state.

The global Symbol registry adds another dimension to Symbol usage. While normal Symbols are always unique, sometimes you need to share Symbols across different parts of code. That’s where Symbol.for() comes in:

JavaScript
// Using Symbol.for() for shared Symbols across modules
const PRIORITY_LEVEL = Symbol.for('priority');
const PROCESS_MESSAGE = Symbol.for('processMessage');
function createMessage(content, priority = 1) {
const message = {
content,
[PRIORITY_LEVEL]: priority,
[PROCESS_MESSAGE]() {
return `Processing: ${this.content} (Priority: ${this[PRIORITY_LEVEL]})`;
}
};
return message;
}
function processMessage(message) {
if (message[PROCESS_MESSAGE]) {
return message[PROCESS_MESSAGE]();
}
throw new Error('Invalid message format');
}
// Usage
const msg = createMessage('Hello World', 2);
console.log(processMessage(msg)); // "Processing: Hello World (Priority: 2)"
// Symbols from registry are shared
console.log(Symbol.for('processMessage') === PROCESS_MESSAGE); // true
// But regular Symbols are not
console.log(Symbol('processMessage') === Symbol('processMessage')); // false

JavaScript provides built-in Symbols that let you modify how objects behave in different situations. These are called well-known Symbols, and they give us hooks into core language features.

One common use case is making objects iterable with Symbol.iterator. This lets us use for...of loops with our own objects, just like we do with arrays:

JavaScript
// Making an object iterable with Symbol.iterator
const tasks = {
items: ['write code', 'review PR', 'fix bugs'],
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index < this.items.length) {
return { value: this.items[index++], done: false };
}
return { value: undefined, done: true };
}
};
}
};
// Now we can use for...of
for (let task of tasks) {
console.log(task); // 'write code', 'review PR', 'fix bugs'
}

Another powerful well-known Symbol is Symbol.toPrimitive. It lets us control how objects convert to primitive values like numbers or strings. This becomes useful when objects need to work with different types of operations:

JavaScript
const user = {
name: 'Alex',
score: 42,
[Symbol.toPrimitive](hint) {
// JavaScript tells us what type it wants with the 'hint' parameter
// hint can be: 'number', 'string', or 'default'
switch (hint) {
case 'number':
return this.score; // When JavaScript needs a number (like +user)
case 'string':
return this.name; // When JavaScript needs a string (like `${user}`)
default:
return `${this.name} (${this.score})`; // For other operations (like user + '')
}
}
};
// Examples of how JavaScript uses these conversions:
console.log(+user); // + operator wants a number, gets 42
console.log(`${user}`); // Template literal wants a string, gets "Alex"
console.log(user + ''); // + with string uses default, gets "Alex (42)"

Inheritance Control with Symbol.species

When working with arrays in JavaScript, we sometimes need to restrict what kind of values they can hold. This is where specialized arrays come in, but they can cause unexpected behavior with methods like map() and filter()

A normal JavaScript array that can hold any type of value:

JavaScript
// Regular array - accepts anything
const regularArray = [1, "hello", true];
regularArray.push(42); // ✅ Works
regularArray.push("world"); // ✅ Works
regularArray.push({}); // ✅ Works

An array that has special rules or behaviors - like only accepting certain types of values:

JavaScript
// Specialized array - only accepts numbers
const createNumberArray = (...numbers) => {
const array = [...numbers];
// Make push only accept numbers
array.push = function(item) {
if (typeof item !== 'number') {
throw new Error('Only numbers allowed');
}
return Array.prototype.push.call(this, item);
};
return array;
};
const numberArray = createNumberArray(1, 2, 3);
numberArray.push(4); // ✅ Works
numberArray.push("5"); // ❌ Error: Only numbers allowed

Think of it like this: a regular array is like an open box that accepts anything, while a specialized array is like a coin slot that only accepts specific items (in this case, numbers).

The problem Symbol.species solves is: when you use methods like map() on a specialized array, do you want the result to be specialized too, or just a regular array?

JavaScript
// specialized array that only accepts numbers
const createNumberArray = (...numbers) => {
const array = [...numbers];
// Restrict push to only allow numbers
array.push = function(item) {
if (typeof item !== 'number') {
throw new Error('Only numbers allowed');
}
return Array.prototype.push.call(this, item);
};
return array;
};
// Test it
const nums = createNumberArray(1, 2, 3);
nums.push(4); // Works ✅
nums.push('5'); // Error! ❌ "Only numbers allowed"
// When we map this array, the restrictions carry over unexpectedly
const doubled = nums.map(x => x * 2);
doubled.push('6'); // Error! ❌ Still restricted to numbers

We can fix this by telling JavaScript to use regular arrays for derived operations. Here’s how Symbol.species solves this:

JavaScript
const createNumberArray = (...numbers) => {
const array = [...numbers];
array.push = function(item) {
if (typeof item !== 'number') {
throw new Error('Only numbers allowed');
}
return Array.prototype.push.call(this, item);
};
// Tell JavaScript to use regular arrays for operations like map()
Object.defineProperty(array.constructor, Symbol.species, {
get: function() { return Array; }
});
return array;
};
const nums = createNumberArray(1, 2, 3);
nums.push(4); // Works ✅
nums.push('5'); // Error! ❌ (as expected for nums)
const doubled = nums.map(x => x * 2);
doubled.push('6'); // Works! ✅ (doubled is a regular array)

Note: Symbol.species is under discussion for potential removal from JavaScript.

Symbols Limitations and Gotchas

Working with Symbols isn’t always straightforward. One common confusion arises when trying to work with JSON. Symbol properties completely disappear during JSON serialization:

JavaScript
const API_KEY = Symbol('apiKey');
// Use that Symbol as a property key
const userData = {
[API_KEY]: 'abc123xyz', // Hidden API key using our Symbol
username: 'alex' // Normal property anyone can see
};
// Later, we can access the API key using our saved Symbol
console.log(userData[API_KEY]); // prints: 'abc123xyz'
// But when we save to JSON, it still disappears
const savedData = JSON.stringify(userData);
console.log(savedData); // Only shows: {"username":"alex"}

String coercion of Symbols leads to another common pitfall. While you might expect Symbols to work like other primitives, they have strict rules about type conversion:

JavaScript
const label = Symbol('myLabel');
// This throws an error
console.log(label + ' is my label'); // TypeError
// Instead, you must explicitly convert to string
console.log(String(label) + ' is my label'); // "Symbol(myLabel) is my label"

Memory handling with Symbols can be tricky, especially when using the global Symbol registry. Regular Symbols can be garbage collected when no references remain, but registry Symbols stick around:

JavaScript
// Regular Symbol can be garbage collected
let regularSymbol = Symbol('temp');
regularSymbol = null; // Symbol can be cleaned up
// Registry Symbol persists
Symbol.for('permanent'); // Creates registry entry
// Even if we don't keep a reference, it stays in registry
console.log(Symbol.for('permanent') === Symbol.for('permanent')); // true

Symbol sharing between modules shows an interesting pattern. When using Symbol.for(), the Symbol becomes available across your entire application, while regular Symbols stay unique:

JavaScript
// In module A
const SHARED_KEY = Symbol.for('app.sharedKey');
const moduleA = {
[SHARED_KEY]: 'secret value'
};
// In module B - even in a different file
const sameKey = Symbol.for('app.sharedKey');
console.log(SHARED_KEY === sameKey); // true
console.log(moduleA[sameKey]); // 'secret value'
// Regular Symbols don't share
const regularSymbol = Symbol('regular');
const anotherRegular = Symbol('regular');
console.log(regularSymbol === anotherRegular); // false

When To Use Symbols

Symbols shine in specific situations. Use them when you need truly unique property keys, like adding metadata that won’t interfere with existing properties. They’re perfect for creating specialized object behaviors through well-known Symbols, and the registry Symbol.for() helps share constants across your application.

JavaScript
// Use symbols for private-like properties
const userIdSymbol = Symbol('id');
const user = {
[userIdSymbol]: 123,
name: 'Alex'
};
// Leverage symbols for special behaviors
const customIterator = {
[Symbol.iterator]() {
// Implement custom iterator logic
}
};
// Share constants across modules using Symbol.for()
const SHARED_ACTION = Symbol.for('action');

Related Articles

If you enjoyed this article, you might find these related pieces interesting as well.

Recommended Engineering Resources

Here are engineering resources I've personally vetted and use. They focus on skills you'll actually need to build and scale real projects - the kind of experience that gets you hired or promoted.

Imagine where you would be in two years if you actually took the time to learn every day. A little effort consistently adds up, shaping your skills, opening doors, and building the career you envision. Start now, and future you will thank you.


This article was originally published on https://www.trevorlasn.com/blog/symbols-in-javascript. It was written by a human and polished using grammar tools for clarity.

Interested in a partnership? Shoot me an email at hi [at] trevorlasn.com with all relevant information.