Every object in JavaScript is a bag of string-keyed properties. That simplicity works until two different parts of your program — or two different libraries — try to store data under the same name. One library adds .id to track a node, another adds .id for its own cache, and the second silently overwrites the first. In large codebases this name collision is not a theoretical risk; it is a regular source of subtle, hard-to-reproduce bugs.
Symbols were added to the language to solve this exact problem. A Symbol is a primitive value that is guaranteed to be unique. Even if two symbols share the same description string, they are not equal. Because they are unique, they make perfect property keys for metadata that should not collide with anything else. You can attach as many symbol-keyed properties as you like to an object, and no string-keyed code will ever trip over them.
flowchart TD
S1["Library A sets<br/>obj.id = 1"] --> C["obj.id overwritten"]
S2["Library B sets<br/>obj.id = 2"] --> C
C --> B["Collision!"]
SYM["Symbol('id')"] --> U["Unique key<br/>no collision"]
style C fill:#b91c1c,color:#fff
style B fill:#b91c1c,color:#fff
style U fill:#1e7f3d,color:#fff
Creating and using Symbols
You create a Symbol by calling the global Symbol() function. You may pass an optional description string for debugging — it shows up in the console and in Symbol.prototype.toString — but the description does not affect uniqueness. Two symbols created with the same description are still completely different values.
const a = Symbol('id');
const b = Symbol('id');
console.log(a === b); // false
Unlike strings, numbers, or booleans, you must not use the new keyword with Symbol. new Symbol() throws a TypeError, because Symbols are primitives, not objects. You can use a Symbol anywhere a property key is expected: in object literals with computed property syntax [sym]: value, with dot notation if you hold the symbol in a variable, or with Object.defineProperty.
const id = Symbol('user_id');
const user = { name: 'Ada' };
user[id] = 42;
console.log(user[id]);
console.log(typeof id);
console.log(id.description);
Hidden but not secret
Symbols occupy an interesting middle ground in JavaScript. They are not hidden from reflection, but they are invisible to the everyday tools that iterate over properties. Object.keys, for...in loops, and JSON.stringify all skip symbol-keyed properties entirely. This makes them ideal for attaching internal state or metadata that should not leak into normal enumeration.
If you genuinely need to inspect them, Object.getOwnPropertySymbols returns an array of every symbol key on an object. Reflect.ownKeys goes further and returns both string and symbol keys together. So symbols are not a security mechanism — they are a collision-avoidance mechanism. Anyone with a reference to the object can find the symbols if they look hard enough. The point is that casual code never stumbles over them by accident.
flowchart TD O["Object with string + symbol keys"] --> K["Object.keys"] --> S["String keys only"] O --> R["Reflect.ownKeys"] --> B["Both string + symbol"] style K fill:#3776ab,color:#fff style R fill:#e0900b,color:#fff style B fill:#1e7f3d,color:#fff
const secret = Symbol('secret');
const obj = { name: 'x', [secret]: 123 };
console.log(Object.keys(obj).length);
console.log(Object.getOwnPropertySymbols(obj).length);
console.log(Reflect.ownKeys(obj).length);
Well-known Symbols
JavaScript defines a set of built-in symbols called well-known symbols. They live on the Symbol constructor itself — Symbol.iterator, Symbol.toStringTag, Symbol.toPrimitive — and they act as hooks into the engine's own behavior. By defining a property with one of these symbol keys, you customize how the engine treats your object in specific contexts.
We have already met Symbol.iterator, the key that makes an object loopable. Symbol.toStringTag controls the tag you see when Object.prototype.toString is called on your object. Instead of the generic [object Object], you can make your class report [object Point] or [object Transaction]. Symbol.toPrimitive lets you control how your object converts to a number, string, or default primitive when used in arithmetic or concatenation. These are not abstract theory; libraries and frameworks use them constantly.
flowchart TD O["Custom object"] --> I["[Symbol.iterator]"] --> L["for...of"] O --> T["[Symbol.toStringTag]"] --> S["[object Point]"] O --> P["[Symbol.toPrimitive]"] --> C["Type coercion"] style O fill:#e0900b,color:#fff style L fill:#3776ab,color:#fff style S fill:#3776ab,color:#fff style C fill:#3776ab,color:#fff
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
get [Symbol.toStringTag]() {
return 'Point';
}
}
const p = new Point(1, 2);
console.log(Object.prototype.toString.call(p));
What does this print? typeof on a Symbol returns the primitive type name.
const s = Symbol('test');
console.log(typeof s);
console.log(s.description);
typeof on any Symbol gives the string 'symbol'.
The description is what you passed to the Symbol constructor.
Write createSecretBox(value) that returns an object with a string property type set to "box" and a symbol-keyed property holding the value. The symbol should have the description "contents". Then write readSecret(box, sym) that returns the value stored under that symbol key.
function createSecretBox(value) {
// return { type: 'box', [Symbol('contents')]: value }
}
function readSecret(box, sym) {
// return the value stored under sym
}
Create the symbol inside createSecretBox and use it as a computed property key.
readSecret simply returns box[sym].
function createSecretBox(value) {
const key = Symbol('contents');
return { type: 'box', [key]: value };
}
function readSecret(box, sym) {
return box[sym];
}
This function is meant to create a symbol with the given description and return it. But it returns the description string instead of the symbol. Fix it so the tests pass.
function makeKey(desc) {
const key = Symbol(desc);
return desc;
}
Return the symbol variable, not the string parameter.
The description property on a symbol reveals the string passed at creation.
function makeKey(desc) {
const key = Symbol(desc);
return key;
}
What does this print? JSON.stringify silently drops symbol-keyed properties.
const s = Symbol('n');
const obj = { a: 1, [s]: 2 };
console.log(JSON.stringify(obj));
JSON.stringify only serializes string-keyed enumerable own properties.
Symbol keys are ignored entirely during serialization.
Write createPoint(x, y) that returns an object with x and y properties and a well-known symbol Symbol.toStringTag set to "Point", so that Object.prototype.toString.call returns "[object Point]".
function createPoint(x, y) {
// return an object with x, y, and Symbol.toStringTag
}
Use a getter keyed by Symbol.toStringTag.
Object.prototype.toString.call is how the engine reads the tag.
function createPoint(x, y) {
return {
x,
y,
get [Symbol.toStringTag]() {
return 'Point';
}
};
}
Recap
Symbol(description)creates a unique primitive that is guaranteed not to collide with any other key.- Symbols work as property keys but are skipped by
Object.keys,for...in, andJSON.stringify. Object.getOwnPropertySymbolsandReflect.ownKeyslet you inspect symbol keys when needed.- Well-known symbols such as
Symbol.iteratorandSymbol.toStringTaghook into built-in behavior. Symbol.toStringTagcustomizes the output ofObject.prototype.toString.call.- Never use
new Symbol()— it throws a TypeError because symbols are primitives. - Do not store persistently-needed data under symbol keys, because
JSON.stringifydrops them.
Next you will learn to safely navigate nested objects with optional chaining and provide precise defaults with nullish coalescing.