DEV Community

Cover image for Working with JavaScript Symbols
Michael Mathews
Michael Mathews

Posted on

Working with JavaScript Symbols

JavaScript Symbols represent a unique primitive data type introduced in ES6.

What Are JavaScript Symbols?

A JavaScript symbol is a primitive data type, like numbers, strings, and booleans. However, symbols have the following distinguishing characteristic: each value returned from Symbol() is guaranteed to be unique within that runtime context (e.g., the window, iframe, worker thread, or VM instance).

Useful properties of symbols include:

  • Are Unique: Every symbol is unique within its runtime context.
  • Are Non-Enumerable: By using a symbol for the name when adding a property to an object, you don't need to worry that the property will suddenly appear in any existing for in loops.
  • Won't be Automatically Type Converted: JavaScript will throw an Error if you accidentally use a symbol in a way where, for example, it would be automatically converted to a string.
  • Are Immutable: Like all JavaScript primitives, once a symbol value is created, that value cannot be changed.

When to Use Symbols

Symbols excel in several scenarios:

Unique Property Keys to Avoid Naming Conflicts

When building a library or working with legacy codebases, you might want to add new properties to existing objects without risking conflicts with existing (or future) properties added by users. Symbols, due to their inherent uniqueness, are perfect for this.

const tag = Symbol("tag");

function setTag(obj, tagName) {
  obj[tag] = tagName;
}

const user = { id: 1, name: "Alice" };
setTag(user, "admin");

console.log(user.id);   // 1
console.log(user[tag]); // admin

// if a regular property "tag" is added it won't clash
user.tag = "loggedIn";
console.log(user.tag); // loggedIn
console.log(user[tag]); // still "admin"

Enter fullscreen mode Exit fullscreen mode

Creating Non-Enumerable Properties

Properties that use symbols for their keys do not appear in for...in loops or Object.keys() results by default. This allows you to "hide" properties, perhaps for adding internal metadata, that shouldn't be part of an object's public interface.

const idSymbol = Symbol("id");
const statusSymbol = Symbol("status");

let obj = {
  name: "Test Object",
  [idSymbol]: "xyz123",
  [statusSymbol]: "active"
};
obj.regularProperty = "I am enumerable";

for (const key in obj) {
  console.log(`  ${key}: ${obj[key]}`);
}
// name: Test Object
// regularProperty: I am enumerable

console.log(Object.keys(obj)); // [ 'name', 'regularProperty' ]

Enter fullscreen mode Exit fullscreen mode

Unique Values for Constants

Since symbols are guaranteed to be unique in their runtime context, they are perfect for defining keys to be used as constants where the actual value doesn't matter as much as its uniqueness.

const LogLevel = {
  DEBUG: Symbol(),
  INFO:  Symbol(),
  WARN:  Symbol(),
  ERROR: Symbol()
};

function logMessage(message, level) {
  if (level === LogLevel.ERROR) {
    console.error(`Critical Error: ${message}`);
  } else if (level === LogLevel.INFO) {
    console.info(`Info: ${message}`);
  }
  // ... other log levels
}

logMessage("Something went wrong!", LogLevel.ERROR);
logMessage("Application started.", LogLevel.INFO);

Enter fullscreen mode Exit fullscreen mode

Creating Symbols

Creating symbols is straightforward using the global Symbol() function, optionally with a description for better debugging. The description is particularly helpful as it appears when logging symbols.

const mySymbol = Symbol("This is a description");
console.log(mySymbol); // Symbol(This is a description)

Enter fullscreen mode Exit fullscreen mode

Using Symbols as Object Property Keys

Just as when using any non-literal string value as a key for an object property, when using symbols as keys, you must use bracket notation. Dot notation, object.propertyName, doesn't work for symbol keys because property names accessed via dot notation are treated as literal strings.

const nameSymbol = Symbol("name");
const person = {
  [nameSymbol]: "Alice", // Using bracket notation to define the property
  age: 30
};

// Accessing the property using the symbol
console.log(person[nameSymbol]); // "Alice"

Enter fullscreen mode Exit fullscreen mode
// Dynamically adding a property with a symbol key
const emailSymbol = Symbol("email");
person[emailSymbol] = "alice@example.com";
console.log(person[emailSymbol]); // "alice@example.com"

Enter fullscreen mode Exit fullscreen mode

Converting Symbols to Strings

Unlike other value types in JavaScript, a symbol cannot be automatically converted to another type. For example:

const arr = ["world"];
const sym = Symbol("world");

console.log("hello " + arr); // hello world (arr was converted to a string)
console.log("hello " + sym); // TypeError: Cannot convert a Symbol value to a string

Enter fullscreen mode Exit fullscreen mode

Converting symbols to a string requires explicit use of the toString() method or the String() constructor. The description property (available in ES2019+) directly returns the description string provided during creation.

const sym = Symbol("hello");

console.log(sym.toString());  // "Symbol(hello)"
console.log(String(sym));     // "Symbol(hello)"
console.log(sym.description); // "hello"

Enter fullscreen mode Exit fullscreen mode

This requirement for explicit conversion is one of the symbol's strengths; it prevents a whole class of bugs from arising from values being unintentionally converted to a different type in operations like concatenation.

Hidden Properties

A useful feature of JavaScript symbols is their ability to create object properties that remain hidden from the usual object inspection methods. When you use symbols as property keys, for example, these properties don't appear in:

  • Object.keys()
  • Object.getOwnPropertyNames()
  • for...in
  • JSON.stringify()

This hidden nature allows developers to safely use symbols to attach metadata, internal state, or utility methods to objects without polluting the regular property namespace.

const myObject = {
  name: "Public Name",
  value: 42,
  [Symbol("id")]: "12345",
  [Symbol("status")]: "inactive"
};

console.log(Object.keys(myObject)); // [ 'name', 'value' ]

Enter fullscreen mode Exit fullscreen mode

That said, this feature is more about preventing unintentional access; symbols don't create private properties in the true sense of the word. Anyone can explicitly interrogate an object for property symbols if they really wish.

const myObject = {
  name: "Public Name",
  value: 42,
  [Symbol("id")]: "12345",
  [Symbol("status")]: "inactive"
};

const symbolKeys = Object.getOwnPropertySymbols(myObject);

symbolKeys.map(symKey => {
  console.log(`${symKey.toString()}: ${myObject[symKey]}`);
});
// Symbol(id): 12345
// Symbol(status): inactive

Enter fullscreen mode Exit fullscreen mode

Global Symbol Registry

The global symbol registry provides a way to share symbols across different parts of your application. The global registry allows you to create and retrieve symbols using string keys, granting access to the same key again when it is needed in multiple locations.

Symbol.for() and Symbol.keyFor()

While Symbol() creates a unique symbol every time, Symbol.for(keyString) searches for an existing symbol with the given keyString in the global symbol registry. If found, it returns that symbol; otherwise, it creates a new symbol with keyString, stores it in the global registry, and returns it.

Symbol.keyFor(symbol) does the reverse: it retrieves the string key associated with a global symbol from the registry.

// Creating or retrieving a global symbol
const globalSym1 = Symbol.for("app.uid");
const globalSym2 = Symbol.for("app.uid");
console.log(globalSym1 === globalSym2); // true

const newSym = Symbol("app.uid");   // not `for` so a brand new symbol
console.log(globalSym1 === newSym); // false

console.log(Symbol.keyFor(globalSym1)); // "app.uid"
console.log(Symbol.keyFor(newSym));     // undefined, not in the registry

Enter fullscreen mode Exit fullscreen mode

This global functionality is useful when you need consistent symbol references across different modules or even different execution contexts (e.g., iframes).

Well-Known Symbols

Well-known symbols are predefined symbols built into JavaScript that allow developers to customize how objects interact with core language features and operations. These symbols act as hooks into JavaScript's internal mechanisms, enabling you to define custom behavior for operations like iteration, type conversion, and instance checking.

JavaScript includes a set of predefined well-known symbols. These are static properties of the Symbol object (e.g., Symbol.iterator, Symbol.toPrimitive, Symbol.hasInstance). They allow developers to customize how objects behave with built-in JavaScript operations and methods.

For example, Symbol.iterator specifies the default iterator for an object, making it compatible with for...of loops. Symbol.hasInstance allows customization of the instanceof operator.

Here's an example of how to customize the instanceof operator with Symbol.hasInstance:

/** An array of strings. */
class List {
  constructor(...items) {
    if (!List.#isListish(items)) {
      throw new TypeError('List must be initialized with an array of strings');
    }
    return [...items];
  }

  static #isListish(obj) {
    // duck typing to decide if obj is a List
    return Array.isArray(obj) && obj.every(i => typeof i === 'string');
  }

  // implement the instanceof protocol
  static [Symbol.hasInstance](obj) {
    return List.#isListish(obj);
  }
}

const arr1 = new List("red", "blue");
const arr2 = ["one", "two"];
const arr3 = [1, 2, 3];
const obj = {};

console.log(arr1 instanceof List); // true
console.log(arr2 instanceof List); // true (array of strings)
console.log(arr3 instanceof List); // false (array items are not strings)
console.log(obj instanceof List);  // false (not an array)

Enter fullscreen mode Exit fullscreen mode

More Use Cases and Patterns

Symbols enable sophisticated programming patterns beyond basic property hiding.

Implementing Custom Iterators with Symbol.iterator

The Symbol.iterator is a well-known symbol that allows you to define custom iteration behavior for your objects, making them compatible with for...of loops and the spread syntax (...).

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  // implement the iterator protocol
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;

    return {
      next: () => {
        if (current <= end) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      },
    };
  }
}

const myRange = new Range(1, 4);

// using for...of
for (const num of myRange) {
  console.log(num); // 1, 2, 3, 4 (on separate lines)
}

// using spread syntax
const numbersArray = [...myRange];
console.log(numbersArray); // [ 1, 2, 3, 4 ]

Enter fullscreen mode Exit fullscreen mode

Metaprogramming

Symbols are useful in metaprogramming scenarios, particularly with Proxies or decorators. They provide stable, unique identifiers for storing metadata or internal logic that won't clash with regular object properties. This separation is crucial for frameworks or libraries that add functionality to user objects without visible side effects.

Registries and Caching

Symbols can create unique, hidden keys for caching mechanisms or registry patterns. By using symbols as keys (e.g., in WeakMaps or as property names for cache storage), developers can create data structures isolated from user code but accessible to the implementing library. This offers stronger encapsulation than convention-based private property naming (like _cacheKey).

Top comments (0)