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:
// 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:
// 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:
// 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:
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:
// 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:
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:
// 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:
// 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:
// 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:
// 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:
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
-
Use descriptions: Always provide meaningful descriptions when creating Symbols.
typescript// Good
const userIdSymbol = Symbol('userId');
// Not as good
const s = Symbol(); -
Store references: Since Symbols are unique, keep references to them if you need to access the property later.
-
Consider visibility needs: Use regular Symbols for truly private properties and global Symbol registry when Symbols need to be shared.
-
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
-
Create a
Cache
class that uses Symbols as keys to store data that won't appear in normal object iteration. -
Implement a custom object that is both iterable (using
Symbol.iterator
) and has a custom string representation (usingSymbol.toStringTag
). -
Use Symbols to create a mixin pattern where properties from multiple sources can be added to an object without name conflicts.
-
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! :)