How to Use the Symbol Type in JavaScript
JavaScript has seven primitive data types, and among them, Symbol is the most unique and least understood. Introduced in ES2015 (ES6), symbols serve a specific purpose: creating guaranteed unique identifiers that can be used as object property keys without any risk of naming collisions.
At first glance, symbols may seem like an obscure feature you will never need. But they solve real problems in JavaScript's design, from preventing property name conflicts in shared objects to enabling powerful customization hooks built into the language itself. Every time you use for...of, convert an object to a string, or work with iterators, you are interacting with symbols behind the scenes.
This guide explains what symbols are, how to create and use them, how they differ from string keys, how the global symbol registry works, and how JavaScript's well-known symbols let you customize the behavior of your objects at a deep level.
What Are Symbols and Why They Exist
A symbol is a primitive value that is guaranteed to be unique. No two symbols are ever equal, even if they have the same description. This uniqueness is the core feature that makes symbols useful.
The Problem Symbols Solve
Consider a scenario where you are working with objects that come from different parts of an application, or from third-party libraries. You need to add a property to an object, but you want to make sure your property name does not accidentally collide with any existing property or with properties added by other code.
With string keys, collisions are always possible:
let user={};
// Library A adds a property
user.id = "lib-a-id-123";
// Library B also adds a property with the same name
user.id = "lib-b-id-456";
// Library A's id is now overwritten: collision!
console.log(user.id) // "lib-b-id-456"
Symbols eliminate this problem entirely:
let user={};
// Library A creates its own unique symbol
const libAId = Symbol("id");
// Library B creates its own unique symbol
const libBId = Symbol("id");
user[libAId] = "lib-a-id-123";
user[libBId] = "lib-b-id-456";
// No collision! Both properties coexist peacefully
console.log(user[libAId]); // "lib-a-id-123"
console.log(user[libBId]); // "lib-b-id-456"
console.log(libAId === libBId); // false (always unique)
Even though both symbols have the same description ("id"), they are completely different values.
Where Symbols Are Used in Practice
- Hidden or "internal" properties that should not interfere with normal object operations
- Preventing name collisions in objects shared across libraries or modules
- Well-known symbols that customize how JavaScript treats your objects (iteration, type conversion, etc.)
- Unique keys for metadata, caching, or framework-internal data attached to user objects
Creating Symbols with Symbol()
Symbols are created using the Symbol() function. Note that Symbol is not a constructor. You cannot use new Symbol().
Basic Creation
let sym = Symbol();
console.log(sym); // Symbol()
console.log(typeof sym); // "symbol"
Symbols with Descriptions
You can pass an optional string description to Symbol(). This description is purely for debugging purposes and does not affect the symbol's uniqueness or behavior:
let id = Symbol("id");
let name = Symbol("name");
console.log(id); // Symbol(id)
console.log(name); // Symbol(name)
console.log(id.toString()); // "Symbol(id)"
console.log(id.description); // "id"
The description makes it easier to identify symbols when debugging, but it has no functional significance.
Every Symbol Is Unique
Even symbols with the same description are completely different values:
let sym1 = Symbol("mySymbol");
let sym2 = Symbol("mySymbol");
console.log(sym1 === sym2); // false (always unique!)
console.log(sym1 == sym2); // false (even with loose equality)
Output:
false
false
There is no way to create two identical symbols using Symbol(). Each call produces a brand-new, unique value. This is the fundamental guarantee.
Symbols Cannot Be Implicitly Converted to Strings
Unlike other primitives, symbols do not auto-convert to strings. This is a safety feature that prevents accidental use in string contexts:
let sym = Symbol("id");
// ❌ TypeError: Cannot convert a Symbol value to a string
// console.log("Symbol: " + sym);
// ❌ TypeError
// alert(sym);
// ✅ Explicit conversion works
console.log(String(sym)); // "Symbol(id)"
console.log(sym.toString()); // "Symbol(id)"
console.log(sym.description); // "id"
Strings and symbols are both valid property keys, but they are fundamentally different. Allowing implicit conversion could lead to confusing bugs where a symbol accidentally becomes a string key. JavaScript forces you to be explicit when converting symbols, keeping the two types cleanly separated.
Symbols Cannot Be Converted to Numbers
let sym = Symbol("test");
// ❌ TypeError: Cannot convert a Symbol value to a number
// let num = +sym;
// let num = sym * 2;
Symbols are truly isolated primitives. They can be compared for identity and converted to strings explicitly, but they resist most other type coercion.
Symbols as Object Property Keys (Hidden Properties)
The primary use case for symbols is as object property keys. When you use a symbol as a key, the property is effectively "hidden" from most standard object inspection methods.
Adding Symbol Properties
Use bracket notation to set and access symbol-keyed properties. Dot notation does not work with symbols:
let id = Symbol("id");
let user = {
name: "Alice",
age: 30
};
// Set a symbol property
user[id] = 101;
// Access a symbol property
console.log(user[id]); // 101
// ❌ Dot notation does NOT work (this creates a string key "id")
user.id = 999;
console.log(user.id); // 999 (this is the string key "id")
console.log(user[id]); // 101 (the symbol key is separate)
Output:
101
999
101
The string key "id" and the symbol key Symbol("id") are completely different properties that coexist on the same object.
Symbols in Object Literals
You can use symbols as keys directly in object literals using computed property syntax:
let id = Symbol("id");
let role = Symbol("role");
let user = {
name: "Alice",
[id]: 101, // Symbol as key (requires brackets)
[role]: "admin" // Another symbol key
};
console.log(user[id]); // 101
console.log(user[role]); // "admin"
console.log(user.name); // "Alice"
Use Case: Adding Metadata Without Interfering
Imagine you receive objects from a third-party library or an API, and you need to attach your own metadata without risking any interference with existing properties or future library updates:
const TRACKING_ID = Symbol("trackingId");
const TIMESTAMP = Symbol("timestamp");
function trackObject(obj) {
obj[TRACKING_ID] = crypto.randomUUID();
obj[TIMESTAMP] = Date.now();
return obj;
}
let product = { name: "Laptop", price: 999 };
trackObject(product);
console.log(product.name); // "Laptop" (original properties intact)
console.log(product[TRACKING_ID]); // "276c3c07-2e25-4e7d-9b42-8bf21025e57b" (random UUID)
console.log(product[TIMESTAMP]); // 1772562553213
// The tracking data is invisible to normal code that iterates over the object
console.log(Object.keys(product)); // ["name", "price"] (symbols not listed!)
The tracking properties exist on the object but are invisible to any code that uses Object.keys(), for...in, or JSON.stringify(). They are hidden metadata.
Symbols Are Skipped in for...in and Object.keys()
Symbol-keyed properties are intentionally excluded from most standard object enumeration methods. This is what makes them "hidden."
What Skips Symbols
let id = Symbol("id");
let user = {
name: "Alice",
age: 30,
[id]: 101
};
// for...in skips symbols
for (let key in user) {
console.log(key);
}
// Output:
// "name"
// "age"
// (no symbol)
// Object.keys() skips symbols
console.log(Object.keys(user)); // ["name", "age"]
// Object.values() skips symbols
console.log(Object.values(user)); // ["Alice", 30]
// Object.entries() skips symbols
console.log(Object.entries(user)); // [["name", "Alice"], ["age", 30]]
// JSON.stringify() skips symbols
console.log(JSON.stringify(user)); // '{"name":"Alice","age":30}'
In every case, the symbol-keyed property [id]: 101 is completely invisible.
How to Access Symbol Properties
If you want to access symbol-keyed properties, JavaScript provides specific methods for that:
let id = Symbol("id");
let role = Symbol("role");
let user = {
name: "Alice",
[id]: 101,
[role]: "admin"
};
// Object.getOwnPropertySymbols() (returns only symbol keys)
console.log(Object.getOwnPropertySymbols(user));
// [Symbol(id), Symbol(role)]
// Reflect.ownKeys() (returns ALL keys (strings + symbols))
console.log(Reflect.ownKeys(user));
// ["name", Symbol(id), Symbol(role)]
Object.assign and Spread Copy Symbols
While most enumeration methods skip symbols, Object.assign() and the spread operator do copy symbol-keyed properties:
let id = Symbol("id");
let original = {
name: "Alice",
[id]: 101
};
let copy = { ...original };
console.log(copy[id]); // 101 (symbol property was copied)
console.log(copy.name); // "Alice"
let assigned = Object.assign({}, original);
console.log(assigned[id]); // 101 (also copied)
This is by design. When you clone an object, you generally want a complete copy, including hidden symbol properties. But when you enumerate or serialize, you typically want only the "public" string-keyed properties.
Summary of Symbol Visibility
| Operation | Includes Symbols? |
|---|---|
for...in | No |
Object.keys() | No |
Object.values() | No |
Object.entries() | No |
JSON.stringify() | No |
Object.getOwnPropertySymbols() | Yes (symbols only) |
Reflect.ownKeys() | Yes (all keys) |
Object.assign() | Yes |
Spread {...obj} | Yes |
Global Symbol Registry: Symbol.for() and Symbol.keyFor()
Regular symbols created with Symbol() are always unique. But sometimes you need to access the same symbol across different parts of your application, across different modules, or even across different iframes. The global symbol registry provides this capability.
Symbol.for(key): Creating or Retrieving Global Symbols
Symbol.for(key) looks up a symbol in the global registry by its key string. If a symbol with that key already exists, it returns the existing symbol. If not, it creates a new symbol, stores it in the registry, and returns it.
// First call: creates a new symbol and registers it with key "app.id"
let sym1 = Symbol.for("app.id");
// Second call: finds the existing symbol with key "app.id" and returns it
let sym2 = Symbol.for("app.id");
console.log(sym1 === sym2); // true (same symbol!)
Output:
true
This is the key difference from Symbol():
Symbol("id")always creates a new unique symbolSymbol.for("id")creates a symbol the first time and returns the same one on subsequent calls
// Regular Symbol (always unique)
let a = Symbol("id");
let b = Symbol("id");
console.log(a === b); // false
// Global Symbol (shared by key)
let c = Symbol.for("id");
let d = Symbol.for("id");
console.log(c === d); // true
Symbol.keyFor(symbol): Getting the Key of a Global Symbol
Symbol.keyFor() takes a global symbol and returns the key string it was registered with:
let globalSym = Symbol.for("app.config");
console.log(Symbol.keyFor(globalSym)); // "app.config"
This only works for symbols created with Symbol.for(). Regular symbols are not in the global registry:
let localSym = Symbol("local");
console.log(Symbol.keyFor(localSym)); // undefined (not a global symbol)
When to Use Global Symbols
Global symbols are useful when different, potentially independent parts of a system need to agree on a shared symbol:
// module-a.js
const STATUS = Symbol.for("app.status");
export function setStatus(obj, status) {
obj[STATUS] = status;
}
// module-b.js (completely separate file)
const STATUS = Symbol.for("app.status"); // Same symbol as module-a.js!
export function getStatus(obj) {
return obj[STATUS];
}
// main.js
import { setStatus } from "./module-a.js";
import { getStatus } from "./module-b.js";
let task = { title: "Learn Symbols" };
setStatus(task, "in-progress");
console.log(getStatus(task)); // "in-progress"
Both modules independently call Symbol.for("app.status") and get the same symbol, even though they have no direct communication. The global registry acts as the shared namespace.
Use dot-separated, namespaced key strings for global symbols to avoid collisions with other libraries: "myapp.userId", "mylib.config.theme". This is similar to how Java packages use reverse domain naming.
Regular vs. Global Symbols Comparison
| Feature | Symbol("desc") | Symbol.for("key") |
|---|---|---|
| Uniqueness | Always unique, even with same description | Same key always returns same symbol |
| Scope | Local to where the reference is stored | Global across the entire runtime |
Symbol.keyFor() | Returns undefined | Returns the registry key |
| Use case | Private/hidden properties in local scope | Shared identifiers across modules |
Well-Known Symbols
JavaScript defines a set of built-in symbols called well-known symbols. These symbols are used as keys for special internal methods that JavaScript calls automatically during certain operations. By defining these symbols as properties on your objects, you can customize how JavaScript treats your objects.
Well-known symbols are the mechanism that lets you control iteration, type conversion, string matching, and many other language-level behaviors.
Symbol.iterator: Making Objects Iterable
Symbol.iterator is the most commonly used well-known symbol. When you use for...of on an object, JavaScript looks for a method with the key Symbol.iterator. If it finds one, it calls it to get an iterator.
let range = {
start: 1,
end: 5,
[Symbol.iterator]() {
let current = this.start;
let last = this.end;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
} else {
return { done: true };
}
}
};
}
};
for (let num of range) {
console.log(num);
}
// 1
// 2
// 3
// 4
// 5
// Also works with spread
console.log([...range]); // [1, 2, 3, 4, 5]
// And destructuring
let [first, second] = range;
console.log(first, second); // 1 2
Without Symbol.iterator, trying to use for...of on this object would throw: TypeError: range is not iterable. The well-known symbol is the hook that enables iteration.
Symbol.toPrimitive: Customizing Type Conversion
When JavaScript needs to convert an object to a primitive (for example, during string concatenation or numeric operations), it looks for a Symbol.toPrimitive method. This method receives a "hint" indicating what type is preferred.
let currency = {
amount: 100,
code: "EUR",
[Symbol.toPrimitive](hint) {
console.log(`Hint: ${hint}`);
if (hint === "number") {
return this.amount;
}
if (hint === "string") {
return `${this.amount} ${this.code}`;
}
// hint === "default" (used by + and ==)
return this.amount;
}
};
// String context
console.log(`Price: ${currency}`);
// Hint: string
// "Price: 100 EUR"
// Numeric context
console.log(+currency);
// Hint: number
// 100
// Default context (e.g., + operator with mixed types)
console.log(currency + 50);
// Hint: default
// 150
Output:
Hint: string
Price: 100 EUR
Hint: number
100
Hint: default
150
The three possible hint values are:
"string": when the object is used in a string context (template literals,String(),alert())"number": when the object is used in a numeric context (unary+,Number(), comparison operators)"default": when the operator works with multiple types (binary+,==)
Symbol.hasInstance: Customizing instanceof
Symbol.hasInstance lets you customize the behavior of the instanceof operator:
class EvenNumber {
static [Symbol.hasInstance](value) {
return typeof value === "number" && value % 2 === 0;
}
}
console.log(4 instanceof EvenNumber); // true
console.log(7 instanceof EvenNumber); // false
console.log("hello" instanceof EvenNumber); // false
Instead of checking the prototype chain, instanceof now calls your custom logic.
Symbol.toStringTag: Customizing Object.prototype.toString
When you call Object.prototype.toString on an object, it returns a string like [object Object]. The Symbol.toStringTag property lets you customize the tag:
let collection = {
items: [1, 2, 3],
[Symbol.toStringTag]: "MyCollection"
};
console.log(Object.prototype.toString.call(collection));
// "[object MyCollection]"
// Built-in objects use this too:
console.log(Object.prototype.toString.call(new Map()));
// "[object Map]"
console.log(Object.prototype.toString.call(new Set()));
// "[object Set]"
console.log(Object.prototype.toString.call(function() {}));
// "[object Function]"
Symbol.species Controlling Derived Object Construction
Symbol.species allows classes to specify which constructor should be used when creating derived objects (for example, when Array.prototype.map creates the result array):
class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
// Ensure methods like map, filter return plain Arrays, not PowerArrays
static get [Symbol.species]() {
return Array;
}
}
let arr = new PowerArray(1, 2, 3, 4, 5);
console.log(arr.isEmpty()); // false
// map creates a new array (but what type?)
let filtered = arr.filter(item => item > 2);
console.log(filtered); // [3, 4, 5]
console.log(filtered instanceof PowerArray); // false (it's a plain Array)
console.log(filtered instanceof Array); // true
// console.log(filtered.isEmpty()); // TypeError (isEmpty doesn't exist on Array)
Complete List of Well-Known Symbols
| Symbol | Purpose |
|---|---|
Symbol.iterator | Defines the default iterator for for...of |
Symbol.asyncIterator | Defines the default async iterator for for await...of |
Symbol.toPrimitive | Customizes object-to-primitive conversion |
Symbol.toStringTag | Customizes Object.prototype.toString output |
Symbol.hasInstance | Customizes instanceof behavior |
Symbol.species | Specifies constructor for derived objects |
Symbol.isConcatSpreadable | Controls Array.prototype.concat spreading |
Symbol.match | Used by String.prototype.match |
Symbol.replace | Used by String.prototype.replace |
Symbol.search | Used by String.prototype.search |
Symbol.split | Used by String.prototype.split |
Symbol.unscopables | Controls with statement visibility |
The most commonly used well-known symbols are Symbol.iterator (for making objects iterable) and Symbol.toPrimitive (for controlling type conversion). The others are used in more specialized scenarios. Focus on understanding the concept: well-known symbols are hooks that let you customize how JavaScript's built-in operations interact with your objects.
Practical Example: Building a Collection Class
Let's combine several symbol concepts into a practical example:
const SIZE = Symbol("size"); // Private-like property
const TYPE = Symbol("type"); // Hidden metadata
class UserCollection {
constructor(users = []) {
this.data = [...users];
this[SIZE] = this.data.length;
this[TYPE] = "UserCollection";
}
add(user) {
this.data.push(user);
this[SIZE] = this.data.length;
}
// Make the collection iterable
[Symbol.iterator]() {
let index = 0;
let data = this.data;
return {
next() {
if (index < data.length) {
return { value: data[index++], done: false };
}
return { done: true };
}
};
}
// Customize type conversion
[Symbol.toPrimitive](hint) {
if (hint === "number") {
return this.data.length;
}
if (hint === "string") {
return `UserCollection(${this.data.length} users)`;
}
return this.data.length;
}
// Customize toString tag
get [Symbol.toStringTag]() {
return "UserCollection";
}
}
let users = new UserCollection([
{ name: "Alice" },
{ name: "Bob" }
]);
users.add({ name: "Charlie" });
// Iteration works
for (let user of users) {
console.log(user.name);
}
// "Alice"
// "Bob"
// "Charlie"
// Spread works
let userArray = [...users];
console.log(userArray.length); // 3
// Type conversion works
console.log(`${users}`); // "UserCollection(3 users)"
console.log(+users); // 3
// toString tag works
console.log(Object.prototype.toString.call(users));
// "[object UserCollection]"
// Symbol properties are hidden from normal enumeration
console.log(Object.keys(users));
// ["data"] (SIZE and TYPE are not listed)
// But can be found if needed
console.log(Object.getOwnPropertySymbols(users));
// [Symbol(size), Symbol(type)]
Summary
- A Symbol is a primitive type that creates a guaranteed unique value. No two symbols are ever equal, even with the same description.
- Create symbols with
Symbol("description"). The description is optional and used only for debugging. Never usenew Symbol(). - Symbols are primarily used as object property keys to create properties that do not collide with string-keyed properties and are hidden from most enumeration methods.
- Symbol properties are skipped by
for...in,Object.keys(),Object.values(),Object.entries(), andJSON.stringify(). They are included byObject.getOwnPropertySymbols(),Reflect.ownKeys(),Object.assign(), and the spread operator. - The global symbol registry (
Symbol.for()andSymbol.keyFor()) provides a way to share the same symbol across different parts of an application or across modules by using a string key. - Well-known symbols (
Symbol.iterator,Symbol.toPrimitive,Symbol.hasInstance, etc.) are built-in symbols used by JavaScript's internal algorithms. Defining them on your objects lets you customize how the language treats those objects (iteration, type conversion,instanceof, etc.). - The most practically important well-known symbols are
Symbol.iterator(enablingfor...ofand spread) andSymbol.toPrimitive(controlling type conversion). - Symbols cannot be implicitly converted to strings or numbers. Use
String(sym),sym.toString(), orsym.descriptionfor explicit conversion.
Symbols fill a unique niche in JavaScript. They are not something you use every day, but when you need guaranteed uniqueness, hidden properties, or deep customization of language behavior, symbols are the right tool for the job.