Well known symbols for precisely customizing the language behavior of your own objects
Symbol.iterator is the best known well known symbol, but far from the only one. This article shows how Symbol.toPrimitive, Symbol.hasInstance and Symbol.toStringTag precisely control the behavior of custom objects during type conversion, instanceof checks and debug output, and how custom symbols enable collision free object keys.
Table of Contents
- 1. Symbol as the seventh primitive type
- 2. Symbol.toPrimitive: controlling type conversion
- 3. Symbol.hasInstance: customizing instanceof behavior
- 4. Symbol.toStringTag: better debug output
- 5. More well known symbols at a glance
- 6. Custom symbols for collision free object keys
- 7. Symbols stay invisible to JSON, Object.keys and for-in
- 8. Symbols as an enum alternative
- 9. Practical summary and overview
- 10. Summary
- 11. FAQ
1. Symbol as the seventh primitive type
Symbol is, alongside string, number, boolean, undefined, null and bigint, the seventh primitive type in JavaScript. Every value created with Symbol() is guaranteed to be unique and immutable, even two symbols with an identical description are never equal. The best known is Symbol.iterator, which defines the iterable protocol, but that is only one of several well known symbols.
Well known symbols are predefined symbols provided by the specification that let custom objects participate in internal language mechanisms, such as type conversion, instanceof checks, or how an object is displayed in debug output. This article deliberately focuses on these lesser known but practically relevant symbols. All well known symbols are reachable as static properties directly on the global Symbol object, such as Symbol.iterator or Symbol.toPrimitive, and do not need to be created yourself, they already exist permanently in every JavaScript environment. Without them, consumers of a library would have to call explicit conversion methods like toNumber() or toDisplayString(), which is easily forgotten and causes an object to unexpectedly show up as [object Object] or NaN.
2. Symbol.toPrimitive: controlling type conversion
Symbol.toPrimitive allows defining a method that JavaScript calls whenever an object needs to be converted to a primitive value, for example during an arithmetic operation, a template literal, or a comparison. The method receives a hint parameter with the value number, string, or default, depending on the context of the conversion.
This lets a money or temperature class, for example, be designed so it behaves like a number in arithmetic expressions but returns a formatted string in template literals, without consumers of the class having to explicitly call a conversion method.
class Money {
constructor(cents) {
this.cents = cents;
}
[Symbol.toPrimitive](hint) {
if (hint === "number") return this.cents / 100;
if (hint === "string") return `${(this.cents / 100).toFixed(2)} EUR`;
return this.cents / 100; // hint === "default"
}
}
const price = new Money(1999);
console.log(+price); // -> 19.99 (hint: number)
console.log(`${price}`); // -> 19.99 EUR (hint: string)
console.log(price + 1); // -> 20.99 (hint: default)
3. Symbol.hasInstance: customizing instanceof behavior
Symbol.hasInstance allows fully defining the behavior of the instanceof operator for a class, instead of relying on the standard prototype chain check. The method is called with the value being tested and must return a boolean.
A practical use case is a duck typing style check, where an object already counts as an instance once it has certain expected methods, regardless of whether it actually inherits from the class. This is especially useful when several independent implementations are meant to satisfy the same interface.
class Serializable {
static [Symbol.hasInstance](instance) {
return instance != null && typeof instance.toJSON === "function";
}
}
console.log({ toJSON: () => "{}" } instanceof Serializable); // -> true
console.log({} instanceof Serializable); // -> false
4. Symbol.toStringTag: better debug output
Symbol.toStringTag controls which string Object.prototype.toString.call(obj) returns for an object. By default, this call returns only [object Object] for custom classes, regardless of the actual class name, which makes introspection and debug output harder.
By defining a custom Symbol.toStringTag getter, the same call instead returns a meaningful tag like [object ReportGenerator]. Libraries often use this to reliably recognize their own data types, especially when instanceof would be unreliable across multiple realms or bundling boundaries.
class ReportGenerator {
get [Symbol.toStringTag]() {
return "ReportGenerator";
}
}
const report = new ReportGenerator();
console.log(Object.prototype.toString.call(report));
// -> [object ReportGenerator]
console.log(Object.prototype.toString.call([]));
// -> [object Array]
5. More well known symbols at a glance
Symbol.isConcatSpreadable controls whether an object is spread out like an array or appended as a single element when Array.prototype.concat is called. This matters for array like objects that are not real arrays but should still combine naturally with concat.
Symbol.species controls which constructor built in methods like map or filter use to create the result object, when a class inherits from Array or a similar built in class. This lets subclasses specify that methods like map still return a plain array instead of the subclass. Both symbols are used directly less often in everyday code than toPrimitive or toStringTag, but they show up regularly in libraries that extend built in types like Array or Promise, and explain behavior there that would otherwise be hard to trace.
6. Custom symbols for collision free object keys
Beyond well known symbols, Symbol() can create any number of custom symbols that serve as guaranteed collision free property keys. Unlike string based keys, two independent libraries can never accidentally use the same symbol key, even if both choose the same description.
Symbol.for() differs by using global registry behavior: two calls to Symbol.for with the same string always return the same symbol, even across module or realm boundaries. This suits interop between independently loaded library versions that need to agree on a shared metadata symbol, while Symbol() is meant for truly private, non shared keys.
const INTERNAL_STATE = Symbol("internalState");
class Widget {
constructor() {
this[INTERNAL_STATE] = { rendered: false };
}
}
const widget = new Widget();
console.log(Object.keys(widget)); // -> [] , symbol key not included
const shared1 = Symbol.for("app.meta");
const shared2 = Symbol.for("app.meta");
console.log(shared1 === shared2); // -> true, global registry
7. Symbols stay invisible to JSON, Object.keys and for-in
Symbol keys are completely ignored by JSON.stringify, do not show up in Object.keys, Object.entries, or a for-in loop, and are also not copied by the object spread syntax by default unless explicitly looked for. This makes symbols ideal for attaching metadata to an object without disturbing normal iteration or serialization.
Anyone who still wants to explicitly access an object's symbol keys uses Object.getOwnPropertySymbols, which returns exclusively symbol keys. Combined with Reflect.ownKeys, both string based and symbol based keys of an object can be fully determined.
const META = Symbol("meta");
const obj = { name: "Widget", [META]: { version: 2 } };
console.log(JSON.stringify(obj)); // -> {"name":"Widget"}
console.log(Object.keys(obj)); // -> ["name"]
console.log(Object.getOwnPropertySymbols(obj)); // -> [Symbol(meta)]
8. Symbols as an enum alternative
Symbols are sometimes used instead of string constants for enum like values, for example for status values like PENDING, ACTIVE, or CLOSED. The advantage over strings is guaranteed uniqueness without collision risk, even if the same string happens to be used elsewhere in the code.
The downside is that symbols are not readily serializable and, without a description, read worse in debug output than meaningful strings. In practice, string constants or, in TypeScript projects, real enums are sufficient for most cases, symbols pay off mainly where collision freedom across module boundaries is genuinely critical. Another practical downside shows up with Redux style state containers or when storing data in localStorage, since symbols simply cannot be represented there without additional conversion logic.
9. Practical summary and overview
Well known symbols are a powerful but targeted tool: they allow custom objects to hook seamlessly into language mechanisms like type conversion, instanceof, or debug output, without changing existing code at each call site. Their use pays off especially in library code consumed by many unknown callers.
Custom symbols, in turn, are the right tool when metadata needs to be attached to an object without affecting normal object iteration, JSON serialization, or the public API. The following table summarizes the most important well known symbols and what triggers each of them.
class Temperature {
#celsius;
constructor(celsius) { this.#celsius = celsius; }
get [Symbol.toStringTag]() { return "Temperature"; }
[Symbol.toPrimitive](hint) {
return hint === "string" ? `${this.#celsius} C` : this.#celsius;
}
static [Symbol.hasInstance](value) {
return value != null && typeof value.#celsius !== "undefined";
}
}
const t = new Temperature(21);
console.log(`${t}`, +t, Object.prototype.toString.call(t));
// -> 21 C 21 [object Temperature]
| Symbol | Purpose | Triggered By | Example Result |
|---|---|---|---|
| Symbol.toPrimitive | Controls type conversion | +, template literal, comparison | Number, string, or default value |
| Symbol.hasInstance | Controls instanceof | value instanceof Class | true or false |
| Symbol.toStringTag | Controls Object.prototype.toString | toString.call(obj) | [object CustomName] |
| Symbol.iterator | Defines the iterable protocol | for-of, spread, destructuring | Iterator with next() |
| Symbol.isConcatSpreadable | Controls Array.prototype.concat | arr.concat(obj) | Spread out or appended as element |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Symbol Primitive: The Essentials at a Glance
Symbol.toPrimitive
Controls how an object converts under the number, string, or default hint
Symbol.hasInstance
Replaces the standard prototype check of instanceof with custom logic
Symbol.toStringTag
Provides a meaningful tag instead of [object Object] in debug output
Custom Symbols
Collision free property keys, invisible to JSON, Object.keys and for-in