Prerequisites
- Understanding of TypeScript generics basics ๐
- Knowledge of classes and OOP concepts ๐
- Familiarity with data structures ๐ป
What you'll learn
- Create powerful generic data structures ๐ฏ
- Build type-safe containers and collections ๐๏ธ
- Implement complex generic class patterns ๐ก๏ธ
- Design reusable class-based libraries โจ
๐ฏ Introduction
Welcome to the world of generic classes! ๐ In this guide, weโll explore how to create flexible, reusable classes that can work with any type while maintaining complete type safety.
Youโll discover how generic classes are like universal containers ๐ฆ - they provide structure and behavior that adapts to whatever you put inside! Whether youโre building collections ๐, data structures ๐ณ, or complex state management systems ๐ฎ, mastering generic classes is essential for creating professional TypeScript libraries.
By the end of this tutorial, youโll be confidently building generic classes that solve real-world problems elegantly! Letโs create some powerful data structures! ๐โโ๏ธ
๐ Understanding Generic Classes
๐ค Why Generic Classes?
Generic classes allow you to create reusable components that maintain type information throughout their lifecycle:
// โ Without generics - lose type safety
class Container {
private value: any;
constructor(value: any) {
this.value = value;
}
getValue(): any {
return this.value;
}
}
const numberContainer = new Container(42);
const value = numberContainer.getValue(); // Type is 'any' ๐ข
// โ
With generics - maintain type safety
class GenericContainer<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
setValue(value: T): void {
this.value = value;
}
}
const numberContainer2 = new GenericContainer(42);
const value2 = numberContainer2.getValue(); // Type is 'number' โจ
console.log(value2.toFixed(2)); // IntelliSense works!
const stringContainer = new GenericContainer('hello');
console.log(stringContainer.getValue().toUpperCase()); // String methods available!
๐ก Generic Class Syntax
Understanding the anatomy of generic classes:
// ๐ฏ Basic generic class structure
class ClassName<T> {
// ^^^ Type parameter declaration
private property: T;
// ^ Using type parameter
constructor(value: T) {
// ^ Type parameter in constructor
this.property = value;
}
method(): T {
// ^ Return type using parameter
return this.property;
}
}
// ๐๏ธ Multiple type parameters
class Pair<T, U> {
constructor(
public first: T,
public second: U
) {}
swap(): Pair<U, T> {
return new Pair(this.second, this.first);
}
}
const pair = new Pair('hello', 42);
const swapped = pair.swap(); // Pair<number, string>
// ๐ง Static members in generic classes
class Registry<T> {
private static instances = new Map<string, Registry<any>>();
private items = new Map<string, T>();
constructor(private name: string) {
Registry.instances.set(name, this);
}
static getInstance<U>(name: string): Registry<U> | undefined {
return Registry.instances.get(name);
}
add(key: string, item: T): void {
this.items.set(key, item);
}
get(key: string): T | undefined {
return this.items.get(key);
}
}
๐ Building Data Structures
๐ Stack Implementation
Creating a type-safe stack data structure:
// ๐ฏ Generic Stack class
class Stack<T> {
private items: T[] = [];
private readonly maxSize?: number;
constructor(maxSize?: number) {
this.maxSize = maxSize;
}
push(item: T): boolean {
if (this.maxSize && this.items.length >= this.maxSize) {
return false; // Stack overflow
}
this.items.push(item);
return true;
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
isFull(): boolean {
return this.maxSize !== undefined && this.items.length >= this.maxSize;
}
size(): number {
return this.items.length;
}
clear(): void {
this.items = [];
}
toArray(): T[] {
return [...this.items];
}
// Iterator support
*[Symbol.iterator](): Iterator<T> {
for (let i = this.items.length - 1; i >= 0; i--) {
yield this.items[i];
}
}
}
// ๐ซ Usage
const numberStack = new Stack<number>(5);
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.peek()); // 3
console.log(numberStack.pop()); // 3
console.log(numberStack.size()); // 2
// Iterate over stack
for (const num of numberStack) {
console.log(num); // 2, 1 (LIFO order)
}
// Complex type stack
interface Task {
id: string;
priority: number;
description: string;
}
const taskStack = new Stack<Task>();
taskStack.push({ id: '1', priority: 1, description: 'Low priority' });
taskStack.push({ id: '2', priority: 5, description: 'High priority' });
๐ณ Binary Tree Implementation
Building a generic binary search tree:
// ๐ฏ Tree node class
class TreeNode<T> {
constructor(
public value: T,
public left: TreeNode<T> | null = null,
public right: TreeNode<T> | null = null
) {}
}
// ๐๏ธ Binary Search Tree with constraints
class BinarySearchTree<T> {
private root: TreeNode<T> | null = null;
constructor(
private compareFn: (a: T, b: T) => number
) {}
insert(value: T): void {
this.root = this.insertNode(this.root, value);
}
private insertNode(node: TreeNode<T> | null, value: T): TreeNode<T> {
if (!node) {
return new TreeNode(value);
}
const comparison = this.compareFn(value, node.value);
if (comparison < 0) {
node.left = this.insertNode(node.left, value);
} else if (comparison > 0) {
node.right = this.insertNode(node.right, value);
}
return node;
}
search(value: T): boolean {
return this.searchNode(this.root, value);
}
private searchNode(node: TreeNode<T> | null, value: T): boolean {
if (!node) return false;
const comparison = this.compareFn(value, node.value);
if (comparison === 0) return true;
if (comparison < 0) return this.searchNode(node.left, value);
return this.searchNode(node.right, value);
}
// In-order traversal
*inOrder(): Generator<T> {
yield* this.inOrderTraversal(this.root);
}
private *inOrderTraversal(node: TreeNode<T> | null): Generator<T> {
if (!node) return;
yield* this.inOrderTraversal(node.left);
yield node.value;
yield* this.inOrderTraversal(node.right);
}
// Convert to sorted array
toArray(): T[] {
return [...this.inOrder()];
}
// Get min and max values
min(): T | undefined {
if (!this.root) return undefined;
let current = this.root;
while (current.left) {
current = current.left;
}
return current.value;
}
max(): T | undefined {
if (!this.root) return undefined;
let current = this.root;
while (current.right) {
current = current.right;
}
return current.value;
}
}
// ๐ซ Usage with different types
// Number tree
const numberTree = new BinarySearchTree<number>((a, b) => a - b);
[5, 3, 7, 1, 9, 4, 6].forEach(n => numberTree.insert(n));
console.log(numberTree.search(4)); // true
console.log(numberTree.toArray()); // [1, 3, 4, 5, 6, 7, 9]
console.log(numberTree.min(), numberTree.max()); // 1, 9
// String tree
const stringTree = new BinarySearchTree<string>((a, b) => a.localeCompare(b));
['dog', 'cat', 'elephant', 'ant', 'bear'].forEach(s => stringTree.insert(s));
console.log(stringTree.toArray()); // ['ant', 'bear', 'cat', 'dog', 'elephant']
// Custom object tree
interface Person {
name: string;
age: number;
}
const personTree = new BinarySearchTree<Person>((a, b) => a.age - b.age);
personTree.insert({ name: 'Alice', age: 30 });
personTree.insert({ name: 'Bob', age: 25 });
personTree.insert({ name: 'Charlie', age: 35 });
for (const person of personTree.inOrder()) {
console.log(`${person.name}: ${person.age}`);
}
๐จ Advanced Generic Patterns
๐ Observable Collections
Building reactive data structures:
// ๐ฏ Observable class with generics
type Observer<T> = (value: T, oldValue?: T) => void;
class ObservableValue<T> {
private observers = new Set<Observer<T>>();
private _value: T;
constructor(initialValue: T) {
this._value = initialValue;
}
get value(): T {
return this._value;
}
set value(newValue: T) {
const oldValue = this._value;
this._value = newValue;
this.notify(newValue, oldValue);
}
subscribe(observer: Observer<T>): () => void {
this.observers.add(observer);
return () => this.observers.delete(observer);
}
private notify(value: T, oldValue?: T): void {
this.observers.forEach(observer => observer(value, oldValue));
}
}
// ๐๏ธ Observable collection
class ObservableList<T> {
private items: T[] = [];
private observers = new Map<string, Set<Function>>();
push(...items: T[]): number {
const length = this.items.push(...items);
this.emit('add', items);
this.emit('change', this.items);
return length;
}
pop(): T | undefined {
const item = this.items.pop();
if (item !== undefined) {
this.emit('remove', [item]);
this.emit('change', this.items);
}
return item;
}
get(index: number): T | undefined {
return this.items[index];
}
set(index: number, value: T): void {
const oldValue = this.items[index];
this.items[index] = value;
this.emit('update', { index, oldValue, newValue: value });
this.emit('change', this.items);
}
on(event: string, callback: Function): () => void {
if (!this.observers.has(event)) {
this.observers.set(event, new Set());
}
this.observers.get(event)!.add(callback);
return () => this.observers.get(event)?.delete(callback);
}
private emit(event: string, data: any): void {
this.observers.get(event)?.forEach(callback => callback(data));
}
toArray(): T[] {
return [...this.items];
}
get length(): number {
return this.items.length;
}
}
// ๐ซ Usage
const numbers = new ObservableList<number>();
numbers.on('add', (items: number[]) => {
console.log('Added:', items);
});
numbers.on('change', (allItems: number[]) => {
console.log('Current list:', allItems);
});
numbers.push(1, 2, 3); // Triggers both events
numbers.set(1, 20); // Updates index 1
๐๏ธ Generic Builder Pattern
Creating flexible builders with generics:
// ๐ฏ Generic builder class
class Builder<T> {
private object: Partial<T> = {};
private validators = new Map<keyof T, (value: any) => boolean>();
set<K extends keyof T>(key: K, value: T[K]): this {
const validator = this.validators.get(key);
if (validator && !validator(value)) {
throw new Error(`Invalid value for ${String(key)}`);
}
this.object[key] = value;
return this;
}
setMany(values: Partial<T>): this {
Object.entries(values).forEach(([key, value]) => {
this.set(key as keyof T, value);
});
return this;
}
addValidator<K extends keyof T>(
key: K,
validator: (value: T[K]) => boolean
): this {
this.validators.set(key, validator);
return this;
}
build(): T {
// In a real implementation, would validate required fields
return this.object as T;
}
reset(): this {
this.object = {};
return this;
}
}
// ๐ Usage example
interface User {
id: string;
name: string;
email: string;
age: number;
roles: string[];
}
const userBuilder = new Builder<User>()
.addValidator('email', (email) => email.includes('@'))
.addValidator('age', (age) => age >= 0 && age <= 150)
.addValidator('roles', (roles) => roles.length > 0);
const user = userBuilder
.set('id', '123')
.set('name', 'John Doe')
.set('email', '[email protected]')
.set('age', 30)
.set('roles', ['user', 'admin'])
.build();
// ๐ง Fluent builder with method chaining
class FluentBuilder<T, R = {}> {
constructor(private current: R) {}
add<K extends string, V>(
key: K,
value: V
): FluentBuilder<T, R & Record<K, V>> {
return new FluentBuilder({
...this.current,
[key]: value
});
}
build(): R {
return this.current;
}
}
// Type-safe building
const config = new FluentBuilder<never, {}>({})
.add('port', 3000)
.add('host', 'localhost')
.add('debug', true)
.build();
// Type is { port: number; host: string; debug: boolean }
๐ช Real-World Applications
๐พ Generic Cache Implementation
Building a sophisticated caching system:
// ๐ฏ Cache entry with metadata
interface CacheEntry<T> {
value: T;
timestamp: number;
hits: number;
size?: number;
}
// ๐๏ธ Advanced generic cache
class Cache<K, V> {
private cache = new Map<K, CacheEntry<V>>();
private accessOrder: K[] = [];
constructor(
private maxSize: number,
private ttl?: number,
private sizeCalculator?: (value: V) => number
) {}
set(key: K, value: V): void {
const size = this.sizeCalculator?.(value) || 1;
// Evict if necessary
while (this.cache.size >= this.maxSize && !this.cache.has(key)) {
this.evictLRU();
}
const entry: CacheEntry<V> = {
value,
timestamp: Date.now(),
hits: 0,
size
};
this.cache.set(key, entry);
this.updateAccessOrder(key);
}
get(key: K): V | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
// Check TTL
if (this.ttl && Date.now() - entry.timestamp > this.ttl) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
return undefined;
}
entry.hits++;
this.updateAccessOrder(key);
return entry.value;
}
has(key: K): boolean {
if (!this.cache.has(key)) return false;
// Check if expired
const entry = this.cache.get(key)!;
if (this.ttl && Date.now() - entry.timestamp > this.ttl) {
this.cache.delete(key);
this.removeFromAccessOrder(key);
return false;
}
return true;
}
delete(key: K): boolean {
this.removeFromAccessOrder(key);
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
this.accessOrder = [];
}
private evictLRU(): void {
const lru = this.accessOrder[0];
if (lru !== undefined) {
this.cache.delete(lru);
this.accessOrder.shift();
}
}
private updateAccessOrder(key: K): void {
this.removeFromAccessOrder(key);
this.accessOrder.push(key);
}
private removeFromAccessOrder(key: K): void {
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
}
getStats(): {
size: number;
hits: number;
misses: number;
hitRate: number;
} {
let totalHits = 0;
let totalAccess = 0;
this.cache.forEach(entry => {
totalHits += entry.hits;
totalAccess += entry.hits + 1; // +1 for initial set
});
return {
size: this.cache.size,
hits: totalHits,
misses: totalAccess - totalHits,
hitRate: totalAccess > 0 ? totalHits / totalAccess : 0
};
}
// Iterator support
*entries(): IterableIterator<[K, V]> {
for (const [key, entry] of this.cache) {
if (!this.ttl || Date.now() - entry.timestamp <= this.ttl) {
yield [key, entry.value];
}
}
}
*[Symbol.iterator](): IterableIterator<[K, V]> {
yield* this.entries();
}
}
// ๐ซ Usage examples
// Simple string cache
const stringCache = new Cache<string, string>(100, 60000); // 100 items, 1 minute TTL
stringCache.set('key1', 'value1');
stringCache.set('key2', 'value2');
// Object cache with size calculation
interface CachedData {
id: string;
content: string;
metadata: Record<string, any>;
}
const dataCache = new Cache<string, CachedData>(
50,
300000, // 5 minutes
(data) => JSON.stringify(data).length // Size based on JSON length
);
dataCache.set('user:123', {
id: '123',
content: 'User data',
metadata: { created: new Date(), role: 'admin' }
});
// Iterate over cache
for (const [key, value] of dataCache) {
console.log(key, value);
}
๐ฎ State Management
Generic state container with history:
// ๐ฏ State with history tracking
class StateContainer<T> {
private currentState: T;
private history: T[] = [];
private future: T[] = [];
private maxHistory: number;
private listeners = new Set<(state: T, prevState: T) => void>();
constructor(initialState: T, maxHistory: number = 50) {
this.currentState = initialState;
this.maxHistory = maxHistory;
}
getState(): T {
return this.currentState;
}
setState(newState: T | ((prev: T) => T)): void {
const prevState = this.currentState;
this.currentState = typeof newState === 'function'
? (newState as (prev: T) => T)(prevState)
: newState;
// Add to history
this.history.push(prevState);
if (this.history.length > this.maxHistory) {
this.history.shift();
}
// Clear future (new timeline)
this.future = [];
// Notify listeners
this.notifyListeners(prevState);
}
undo(): boolean {
if (this.history.length === 0) return false;
const prevState = this.currentState;
const newState = this.history.pop()!;
this.future.push(prevState);
this.currentState = newState;
this.notifyListeners(prevState);
return true;
}
redo(): boolean {
if (this.future.length === 0) return false;
const prevState = this.currentState;
const newState = this.future.pop()!;
this.history.push(prevState);
this.currentState = newState;
this.notifyListeners(prevState);
return true;
}
subscribe(listener: (state: T, prevState: T) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(prevState: T): void {
this.listeners.forEach(listener => listener(this.currentState, prevState));
}
getHistory(): T[] {
return [...this.history];
}
canUndo(): boolean {
return this.history.length > 0;
}
canRedo(): boolean {
return this.future.length > 0;
}
}
// ๐ซ Usage
interface AppState {
count: number;
user: string | null;
todos: string[];
}
const state = new StateContainer<AppState>({
count: 0,
user: null,
todos: []
});
// Subscribe to changes
state.subscribe((newState, prevState) => {
console.log('State changed:', { prevState, newState });
});
// Make changes
state.setState(prev => ({
...prev,
count: prev.count + 1
}));
state.setState(prev => ({
...prev,
user: 'John'
}));
state.setState(prev => ({
...prev,
todos: [...prev.todos, 'Learn TypeScript']
}));
// Undo/Redo
console.log('Can undo:', state.canUndo()); // true
state.undo();
console.log('Current state:', state.getState());
state.redo();
console.log('After redo:', state.getState());
๐ฎ Hands-On Exercise
Letโs build a generic priority queue!
๐ Challenge: Priority Queue Implementation
Create a priority queue that:
- Supports any type with a priority
- Maintains heap property for O(log n) operations
- Provides iteration in priority order
- Supports priority updates
// Your challenge: Implement this priority queue
interface PriorityItem<T> {
value: T;
priority: number;
}
class PriorityQueue<T> {
// Implement:
// - enqueue(item: T, priority: number): void
// - dequeue(): T | undefined
// - peek(): T | undefined
// - updatePriority(item: T, newPriority: number): boolean
// - size(): number
// - isEmpty(): boolean
// - clear(): void
// - toArray(): T[] (in priority order)
}
// Example usage to support:
interface Task {
id: string;
name: string;
}
const taskQueue = new PriorityQueue<Task>();
taskQueue.enqueue({ id: '1', name: 'Low priority' }, 1);
taskQueue.enqueue({ id: '2', name: 'High priority' }, 10);
taskQueue.enqueue({ id: '3', name: 'Medium priority' }, 5);
const highestPriority = taskQueue.dequeue(); // Should return high priority task
๐ก Solution
Click to see the solution
// ๐ฏ Priority Queue implementation with min-heap
class PriorityQueue<T> {
private heap: PriorityItem<T>[] = [];
private itemMap = new Map<T, number>(); // Maps items to their indices
enqueue(value: T, priority: number): void {
const item: PriorityItem<T> = { value, priority };
this.heap.push(item);
const index = this.heap.length - 1;
this.itemMap.set(value, index);
this.bubbleUp(index);
}
dequeue(): T | undefined {
if (this.isEmpty()) return undefined;
const root = this.heap[0];
const last = this.heap.pop()!;
if (this.heap.length > 0) {
this.heap[0] = last;
this.itemMap.set(last.value, 0);
this.bubbleDown(0);
}
this.itemMap.delete(root.value);
return root.value;
}
peek(): T | undefined {
return this.heap[0]?.value;
}
updatePriority(value: T, newPriority: number): boolean {
const index = this.itemMap.get(value);
if (index === undefined) return false;
const oldPriority = this.heap[index].priority;
this.heap[index].priority = newPriority;
// Restore heap property
if (newPriority > oldPriority) {
this.bubbleUp(index);
} else {
this.bubbleDown(index);
}
return true;
}
size(): number {
return this.heap.length;
}
isEmpty(): boolean {
return this.heap.length === 0;
}
clear(): void {
this.heap = [];
this.itemMap.clear();
}
toArray(): T[] {
// Return items in priority order without modifying the heap
const sorted = [...this.heap].sort((a, b) => b.priority - a.priority);
return sorted.map(item => item.value);
}
private bubbleUp(index: number): void {
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2);
if (this.heap[parentIndex].priority >= this.heap[index].priority) {
break;
}
this.swap(index, parentIndex);
index = parentIndex;
}
}
private bubbleDown(index: number): void {
while (true) {
let largest = index;
const leftChild = 2 * index + 1;
const rightChild = 2 * index + 2;
if (leftChild < this.heap.length &&
this.heap[leftChild].priority > this.heap[largest].priority) {
largest = leftChild;
}
if (rightChild < this.heap.length &&
this.heap[rightChild].priority > this.heap[largest].priority) {
largest = rightChild;
}
if (largest === index) break;
this.swap(index, largest);
index = largest;
}
}
private swap(i: number, j: number): void {
const temp = this.heap[i];
this.heap[i] = this.heap[j];
this.heap[j] = temp;
// Update map
this.itemMap.set(this.heap[i].value, i);
this.itemMap.set(this.heap[j].value, j);
}
// Iterator support
*[Symbol.iterator](): Iterator<T> {
const sorted = this.toArray();
for (const item of sorted) {
yield item;
}
}
// Debug method
visualize(): void {
console.log('Priority Queue Heap:');
for (let i = 0; i < this.heap.length; i++) {
const level = Math.floor(Math.log2(i + 1));
const spaces = ' '.repeat(level * 2);
console.log(`${spaces}[${this.heap[i].priority}] ${JSON.stringify(this.heap[i].value)}`);
}
}
}
// ๐๏ธ Extended priority queue with custom comparator
class CustomPriorityQueue<T> extends PriorityQueue<T> {
private compareFn: (a: T, b: T) => number;
constructor(compareFn?: (a: T, b: T) => number) {
super();
this.compareFn = compareFn || (() => 0);
}
enqueueBatch(items: Array<{ value: T; priority: number }>): void {
items.forEach(item => this.enqueue(item.value, item.priority));
}
dequeueMultiple(count: number): T[] {
const results: T[] = [];
for (let i = 0; i < count && !this.isEmpty(); i++) {
const item = this.dequeue();
if (item !== undefined) {
results.push(item);
}
}
return results;
}
}
// ๐ซ Test the implementation
interface Task {
id: string;
name: string;
createdAt: Date;
}
const taskQueue = new PriorityQueue<Task>();
// Add tasks
taskQueue.enqueue(
{ id: '1', name: 'Write tests', createdAt: new Date() },
3
);
taskQueue.enqueue(
{ id: '2', name: 'Fix critical bug', createdAt: new Date() },
10
);
taskQueue.enqueue(
{ id: '3', name: 'Code review', createdAt: new Date() },
5
);
taskQueue.enqueue(
{ id: '4', name: 'Documentation', createdAt: new Date() },
2
);
console.log('Tasks in priority order:');
for (const task of taskQueue) {
console.log(`- ${task.name}`);
}
// Process highest priority
const urgent = taskQueue.dequeue();
console.log('\nProcessing:', urgent?.name);
// Update priority
const docTask = { id: '4', name: 'Documentation', createdAt: new Date() };
taskQueue.updatePriority(docTask, 8);
console.log('\nAfter priority update:');
taskQueue.visualize();
// Test with different types
const numberQueue = new PriorityQueue<number>();
[42, 17, 35, 8, 91].forEach((num, i) => {
numberQueue.enqueue(num, num); // Using number itself as priority
});
console.log('\nNumbers by priority:', numberQueue.toArray());
๐ฏ Summary
Youโve mastered generic classes in TypeScript! ๐ You learned how to:
- ๐ฆ Create flexible, reusable class-based data structures
- ๐ณ Build complex generic collections like trees and queues
- ๐ Implement observable and reactive patterns
- ๐พ Design sophisticated caching systems
- ๐ฎ Create state management solutions with history
- โจ Maintain complete type safety throughout
Generic classes are fundamental for building professional TypeScript libraries and applications. They enable you to create reusable components that work with any type while providing excellent developer experience!
Keep building amazing generic data structures! ๐