The Symbol Primitive: Well Known Symbols Beyond Symbol.iterator
AI generated
JS
() =>
JavaScript · Symbols · Language Features
The Symbol Primitive Beyond Symbol.iterator
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.

15 min read Symbol Well Known Symbols Metaprogramming

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

11. FAQ: Symbol Primitive: The Essentials at a Glance

1What is a well known symbol in JavaScript?
A well known symbol is a symbol predefined by the ECMAScript specification through which custom objects can participate in internal language mechanisms like type conversion, instanceof, or iteration.
2What is Symbol.toPrimitive used for?
Symbol.toPrimitive defines a method that gets called when an object needs to be converted to a primitive value, for example during arithmetic operations or template literals, controlled via a hint parameter.
3Can I define the behavior of instanceof myself?
Yes, with Symbol.hasInstance you can define a custom static method that is called on every instanceof check and returns a boolean, independent of the prototype chain.
4What does Symbol.toStringTag do?
Symbol.toStringTag controls which string Object.prototype.toString.call returns, so a meaningful custom tag is shown instead of the generic [object Object].
5What is the difference between Symbol() and Symbol.for()?
Symbol() creates a new, unique symbol every time. Symbol.for() uses a global registry and always returns the same symbol for an identical string, even across module boundaries.
6Do symbol keys show up in JSON.stringify?
No, JSON.stringify completely ignores symbol keys. Object.keys, Object.entries, and for-in loops also do not display them by default.
7How do I explicitly access an object's symbol keys?
Object.getOwnPropertySymbols returns exclusively an object's symbol keys, and Reflect.ownKeys additionally returns the string based keys as well.
8Are symbols a good replacement for string enums?
Symbols offer guaranteed uniqueness without collision risk, but are harder to serialize and less readable in debug output than meaningful string constants.
9What is Symbol.isConcatSpreadable used for?
Symbol.isConcatSpreadable controls whether an object is spread out like an array or appended as a single element when Array.prototype.concat is called, relevant for array like objects.
10Is Symbol really its own primitive type?
Yes, Symbol is, alongside string, number, boolean, undefined, null, and bigint, the seventh primitive type in JavaScript, recognizable with the typeof operator as typeof value === 'symbol'.