Skip to main content

TypeScript Symbols

Introduction

Symbols were introduced in ECMAScript 2015 (ES6) as a new primitive data type and are fully supported in TypeScript. Unlike strings or numbers, Symbols are guaranteed to be unique and immutable, making them ideal for creating special object properties and constants that won't conflict with other properties or values.

In this lesson, we'll explore how Symbols work in TypeScript, their unique characteristics, and practical applications that showcase their power in modern TypeScript development.

Understanding Symbols

What Are Symbols?

A Symbol is a unique and immutable primitive value that may be used as an identifier for object properties. The most important feature of Symbols is their uniqueness - every Symbol value returned from Symbol() is guaranteed to be unique.

Let's create our first Symbol:

typescript
// Creating a basic Symbol
const mySymbol = Symbol();
console.log(typeof mySymbol); // "symbol"
console.log(mySymbol); // Symbol()

Symbols with Descriptions

You can provide a description when creating a Symbol, which helps with debugging but doesn't affect its uniqueness:

typescript
// Creating Symbols with descriptions
const sym1 = Symbol("key");
const sym2 = Symbol("key");

console.log(sym1 === sym2); // false - each Symbol is unique
console.log(sym1.toString()); // "Symbol(key)"
console.log(sym2.toString()); // "Symbol(key)"

Even though sym1 and sym2 have the same description, they are completely different Symbols.

Using Symbols as Object Keys

One of the most common uses for Symbols is as unique property keys in objects:

typescript
// Using Symbols as object keys
const nameSymbol = Symbol("name");
const person = {
[nameSymbol]: "John",
age: 30
};

console.log(person[nameSymbol]); // "John"
console.log(person["name"]); // undefined - Symbol keys are different from strings
console.log(Object.keys(person)); // ["age"] - Symbol properties are not included

Notice that Symbol properties don't appear in Object.keys() or for...in loops, making them suitable for properties that shouldn't be enumerated in standard object iterations.

Symbol Properties and Enumeration

Symbols create non-enumerable properties by default:

typescript
const id = Symbol("id");
const user = {
name: "Alice",
[id]: 123
};

// Symbol properties don't appear in standard iterations
console.log(Object.keys(user)); // ["name"]
console.log(Object.getOwnPropertyNames(user)); // ["name"]

// But you can access them with specific methods
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]

// Or get all properties including Symbols
console.log(Reflect.ownKeys(user)); // ["name", Symbol(id)]

Well-Known Symbols

TypeScript supports JavaScript's "well-known" Symbols, which are built-in Symbol values that enable you to customize object behavior:

Symbol.iterator

One of the most useful well-known Symbols is Symbol.iterator, which allows you to define custom iteration behavior:

typescript
// Making an object iterable using Symbol.iterator
class CustomCollection {
private items: string[] = ["item1", "item2", "item3"];

[Symbol.iterator]() {
let index = 0;
const items = this.items;

return {
next: () => {
if (index < items.length) {
return { value: items[index++], done: false };
} else {
return { value: undefined, done: true };
}
}
};
}
}

const collection = new CustomCollection();

// Now we can use a for...of loop on our custom object
for (const item of collection) {
console.log(item);
}
// Output:
// "item1"
// "item2"
// "item3"

Symbol.toStringTag

This well-known symbol allows you to customize the default toString behavior of your objects:

typescript
class MyCustomClass {
[Symbol.toStringTag] = "MyCustomClass";
}

const obj = new MyCustomClass();
console.log(Object.prototype.toString.call(obj)); // "[object MyCustomClass]"

Global Symbol Registry

Sometimes, you need to share Symbols across different parts of your code. The global Symbol registry lets you create and reuse Symbols that are accessible globally:

typescript
// Creating a Symbol in the global registry
const globalSymbol1 = Symbol.for("globalId");
const globalSymbol2 = Symbol.for("globalId");

console.log(globalSymbol1 === globalSymbol2); // true - same Symbol is returned

// Getting the key from a global Symbol
console.log(Symbol.keyFor(globalSymbol1)); // "globalId"

// Regular Symbols aren't in the registry
const localSymbol = Symbol("localId");
console.log(Symbol.keyFor(localSymbol)); // undefined

Practical Applications of Symbols

1. Private or Meta Properties

Symbols are perfect for adding "private" or meta properties to objects without polluting the regular property namespace:

typescript
// Using Symbols for meta properties
const _metadata = Symbol('metadata');

class Document {
title: string;
content: string;

constructor(title: string, content: string) {
this.title = title;
this.content = content;
this[_metadata] = {
created: new Date(),
modified: new Date(),
views: 0
};
}

updateMetadata() {
this[_metadata].modified = new Date();
this[_metadata].views++;
}

getMetadata() {
return { ...this[_metadata] };
}
}

const doc = new Document("TypeScript Guide", "Content here...");
console.log(doc.getMetadata()); // { created: Date, modified: Date, views: 0 }

// The metadata can't be accessed directly without the Symbol
console.log(Object.keys(doc)); // ["title", "content"]

2. Preventing Property Name Collisions

When integrating with libraries or working on large codebases, Symbols help prevent name collisions:

typescript
// Different modules can define their own properties without conflicts
const libraryA = {
id: Symbol('id'),
initialize(obj: any, value: number) {
obj[this.id] = value;
}
};

const libraryB = {
id: Symbol('id'), // Different symbol, no collision
initialize(obj: any, value: string) {
obj[this.id] = value;
}
};

const object = {};
libraryA.initialize(object, 123);
libraryB.initialize(object, "hello");

console.log(object[libraryA.id]); // 123
console.log(object[libraryB.id]); // "hello"

3. Extending Existing Objects Safely

You can safely extend objects you don't own without worrying about future property conflicts:

typescript
// Safely extend native objects
const toReversed = Symbol('toReversed');

// Extend Array prototype safely
Array.prototype[toReversed] = function() {
return [...this].reverse();
};

const array = [1, 2, 3, 4, 5];
console.log(array[toReversed]()); // [5, 4, 3, 2, 1]
console.log(array); // [1, 2, 3, 4, 5] - original unchanged

// Even if Array adds a toReversed method in the future, our Symbol version won't conflict

4. Custom Implementation of Well-Known Behaviors

Symbols let you implement standard JavaScript behavior patterns in your custom objects:

typescript
class CustomMap<K, V> {
private data = new Map<K, V>();

set(key: K, value: V): this {
this.data.set(key, value);
return this;
}

get(key: K): V | undefined {
return this.data.get(key);
}

// Implement custom behavior for Symbol.toPrimitive
[Symbol.toPrimitive](hint: string) {
if (hint === 'number') {
return this.data.size;
}
if (hint === 'string') {
return `CustomMap(${this.data.size})`;
}
return this.data;
}
}

const map = new CustomMap<string, number>()
.set('a', 1)
.set('b', 2);

console.log(String(map)); // "CustomMap(2)"
console.log(+map); // 2

Best Practices for Using Symbols

  1. Use descriptions: Always provide meaningful descriptions when creating Symbols.

    typescript
    // Good
    const userIdSymbol = Symbol('userId');

    // Not as good
    const s = Symbol();
  2. Store references: Since Symbols are unique, keep references to them if you need to access the property later.

  3. Consider visibility needs: Use regular Symbols for truly private properties and global Symbol registry when Symbols need to be shared.

  4. Document Symbol usage: Make sure your team understands why and how you're using Symbols in your codebase.

Summary

Symbols bring a powerful primitive type to TypeScript that enables:

  • Creating truly unique property keys
  • Adding non-enumerable properties to objects
  • Preventing property name collisions
  • Customizing object behaviors through well-known Symbols
  • Sharing Symbols across code through the global Symbol registry

While not needed in every application, Symbols provide elegant solutions to specific problems in TypeScript and JavaScript programming, especially when working with metadata, extending objects, or creating custom iterables.

Exercises

  1. Create a Cache class that uses Symbols as keys to store data that won't appear in normal object iteration.

  2. Implement a custom object that is both iterable (using Symbol.iterator) and has a custom string representation (using Symbol.toStringTag).

  3. Use Symbols to create a mixin pattern where properties from multiple sources can be added to an object without name conflicts.

  4. Create a versioning system for objects where each version is stored using Symbol properties, allowing you to maintain object history without polluting its public interface.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)