Symbols in JavaScript?
Most JavaScript developers have never used "Symbols", not because symbols useless but because their use cases are not obvious until you know them. In this post, I'll try to help you understand:
- What the heck are Symbols are and why they exist
- How to create and use them
- Some real world use cases where Symbols shine
- When NOT to use Symbols
Let's git init.
What is a Symbol?
A Symbol is a primitive value that is guaranteed to be unique. That's it. That's the core idea.
const sym1 = Symbol();
const sym2 = Symbol();
console.log(sym1 === sym2); // false, always
Even if you pass the same description string, two Symbols are never equal:
const a = Symbol('id');
const b = Symbol('id');
console.log(a === b); // false
console.log(typeof a); // "symbol"
The description ('id') is just a label for debugging. It has no effect on identity.
Why do Symbols exist?
Before Symbols, there was no way to guarantee a property key wouldn't collide with another. Using a string key like "id" on an object means any other piece of code could accidentally overwrite it by using the same key.
Symbols solve this by being inherently unique keys. No two independently created Symbols will ever be the same, which means you can attach data to objects without worrying about name collisions.
Let's see where this actually matters.
Use Case #1: Avoiding Property Name Collisions
Imagine you're working with user objects that come from different parts of your app — maybe one module sets an id, and another module also wants to track its own id on the same object.
The problem with string keys:
// Module A
function addTrackingId(user) {
user.id = 'tracking-abc-123';
}
// Module B
function addDatabaseId(user) {
user.id = 'db-user-42';
}
const user = { name: 'John' };
addTrackingId(user);
addDatabaseId(user);
console.log(user.id); // 'db-user-42': the tracking id is gone :(
Module B silently overwrote Module A's value without any error or warning.
The fix with Symbols:
// Module A
const trackingId = Symbol('trackingId');
function addTrackingId(user) {
user[trackingId] = 'tracking-abc-123';
}
// Module B
const databaseId = Symbol('databaseId');
function addDatabaseId(user) {
user[databaseId] = 'db-user-42';
}
const user = { name: 'John' };
addTrackingId(user);
addDatabaseId(user);
console.log(user[trackingId]); // 'tracking-abc-123'
console.log(user[databaseId]); // 'db-user-42'
This pattern is so useful in library or plugin code where you're decorating objects you don't own.
Use Case #2: "Private-ish" Object Properties
Symbol keyed properties don't show up in most common ways of inspecting objects:
const _secret = Symbol('secret');
const config = {
apiUrl: 'http://localhost:8000/api',
timeout: 5000,
[_secret]: 'my-api-key-12345'
};
console.log(Object.keys(config)); // ['apiUrl', 'timeout']
console.log(JSON.stringify(config)); // {"apiUrl":"http://localhost:8000/api","timeout":5000}
for (const key in config) {
console.log(key); // 'apiUrl', 'timeout'
}
The Symbol-keyed property is invisible to Object.keys(), for...in, and JSON.stringify(). This makes Symbols great for storing metadata on objects that you don't want leaking to consumers or getting accidentally serialized.
The caveat
Symbols are not truly private. If someone really wants to find them, they can:
console.log(Object.getOwnPropertySymbols(config)); // [Symbol(secret)]
console.log(config[Object.getOwnPropertySymbols(config)[0]]); // 'my-api-key-12345'
So think of it as "hidden from casual access" rather than "actually private". If you need actual privacy, use private class fields (#), which we'll cover at the end.
Use Case #3: Built-in Symbols
JavaScript has a set of built-in Symbols that let you customize how your objects behave with language-level operations.
Symbol.iterator — Make anything iterable
Have you ever wondered how for...of knows how to loop over an array but throws an error on a plain object? It's because arrays have a Symbol.iterator method, and plain objects don't.
You can add one to any object:
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
}
const range = new Range(1, 5);
for (const num of range) {
console.log(num); // 1, 2, 3, 4, 5
}
// Works with spread too
console.log([...range]); // [1, 2, 3, 4, 5]
// And destructuring
const [first, second] = new Range(10, 20);
console.log(first, second); // 10 11
By defining [Symbol.iterator](), your object now works with for...of, the spread operator, destructuring, Array.from(), and any other construct that expects an iterable. You're essentially teaching JavaScript how to iterate over your custom data structure.
Symbol.toPrimitive — Control type conversion
When JS needs to convert your object to a primitive (like when you use + or put it in a template literal), it calls Symbol.toPrimitive if it exists:
class Currency {
constructor(amount, code) {
this.amount = amount;
this.code = code;
}
[Symbol.toPrimitive](hint) {
if (hint === 'number') {
return this.amount;
}
if (hint === 'string') {
return `${this.amount} ${this.code}`;
}
// default hint
return this.amount;
}
}
const price = new Currency(49.99, 'USD');
console.log(`Total: ${price}`); // "Total: 49.99 USD" (string hint)
console.log(price + 10); // 59.99 (number hint)
console.log(price > 40); // true (number hint)
Without Symbol.toPrimitive, you'd get Total: [object Object]. With it, your object knows exactly how to represent itself depending on the context.
Other well-known Symbols worth knowing:
Symbol.hasInstance— Customizes the behavior ofinstanceofSymbol.toStringTag— Customizes whatObject.prototype.toString.call(obj)returnsSymbol.species— Controls which constructor is used when creating derived objects (e.g., when calling.map()on a subclass of Array)
Use Case #4: Symbols as Enum like constants
In many languages, you have enum types for representing a fixed set of values. JavaScript doesn't have native enums (TypeScript does, but that's a different story). Using strings for this is not ideal:
function handleStatus(status) {
if (status === 'pendng') {
// typo! No error is thrown.
}
}
handleStatus('pending');
With Symbols, each value is unique and cannot be accidentally recreated:
const Status = Object.freeze({
PENDING: Symbol('PENDING'),
ACTIVE: Symbol('ACTIVE'),
CLOSED: Symbol('CLOSED')
});
function handleStatus(status) {
switch (status) {
case Status.PENDING:
console.log('Waiting to start...');
break;
case Status.ACTIVE:
console.log('In progress!');
break;
case Status.CLOSED:
console.log('Done.');
break;
default:
throw new Error(`Unknown status: ${String(status)}`);
}
}
handleStatus(Status.ACTIVE); // "In progress!"
handleStatus('ACTIVE'); // throws Error
Because Symbol('ACTIVE') !== Symbol('ACTIVE'), the only way to pass a valid status is by using the actual Status.ACTIVE reference. This prevents typos and makes invalid states unrepresentable. You're essentially getting type safety without TypeScript.
Symbol.for() — The Global Symbol Registry
Sometimes you actually want the same Symbol across different parts of your app, or even across different iframes. That's what Symbol.for() is for:
// In module A
const userId = Symbol.for('app.userId');
// In module B (even a completely different file)
const sameUserId = Symbol.for('app.userId');
console.log(userId === sameUserId); // true!
Symbol.for('key') checks a global registry. If a Symbol with that key already exists, it returns it. Otherwise, it creates a new one and registers it.
You can also look up the key for a registered Symbol:
const sym = Symbol.for('app.userId');
console.log(Symbol.keyFor(sym)); // 'app.userId'
const localSym = Symbol('local');
console.log(Symbol.keyFor(localSym)); // undefined: not registered
When to use Symbol.for() vs Symbol():
- Use
Symbol()when you want a guaranteed unique key (most of the time you'll need this) - Use
Symbol.for()when you need to share a Symbol across module boundaries, iframes, or different execution contexts
Symbols vs Private Class Fields (#)
JavaScript has the support for true private fields:
class User {
#secret; // truly private, cannot be accessed from outside
constructor(name, secret) {
this.name = name;
this.#secret = secret;
}
checkSecret(input) {
return input === this.#secret;
}
}
const user = new User('John', 'secret123');
console.log(user.name); // 'John'
console.log(user.#secret); // SyntaxError!
So when should you use which?
| Symbol | Private Field (#) | |
|---|---|---|
| Access | Hidden but discoverable | Truly inaccessible |
| Where | Any object | Only in classes |
| Serialization | Skipped by JSON.stringify | Skipped by JSON.stringify |
| Best for | Metadata on objects you don't own, collision free keys | Internal class state |
Rule of thumb: Use # for class internals. Use Symbols when you need collision free keys on objects you don't control, or when you want to leverage the built-in Symbols.
When NOT to Use Symbols
You should skip them when:
- You're just building a regular app — String keys are fine when you control the entire codebase.
- You need to serialize data — Symbols are invisible to
JSON.stringify(). If your data needs to go over the network or into a database. - You want true privacy — Use
#privateFieldinstead. Symbols are "hidden" but not "private". - A simple
constwould work — Don't useSymbol('STATUS_ACTIVE')when a plainconst STATUS_ACTIVE = 'active'does the job.
TL;DR
| Use Case | Why Symbols Work |
|---|---|
| Collision free property keys | Every Symbol is unique — no two are the same |
| Hidden object metadata | Invisible to for...in, Object.keys(), JSON.stringify() |
Custom iteration (Symbol.iterator) | Hook into for...of, spread, destructuring |
Custom type conversion (Symbol.toPrimitive) | Control how your object converts to string/number |
| Enum like constants | Can't be accidentally recreated or faked with strings |
Cross module shared keys (Symbol.for) | Global registry ensures same Symbol across boundaries |
That is all i've for you about symbols. The next time you're building a library or plugin, decorating 3rd party objects, or making your custom data structure iterable, you'll know exactly which tool to reach for in javascript.
Peace out!