Lesson 5.3: Generic Classes and Interfaces
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Create generic interfaces for reusable type definitions
- Build generic classes for type-safe data structures
- Use constraints in classes and interfaces
- Implement generic interfaces in classes
- Understand when to use generic classes vs functions
Generic Interfaces
Interfaces can have type parameters just like functions:
interface Container<T> {
value: T;
getValue(): T;
setValue(value: T): void;
}
// Implementation with string
const stringContainer: Container<string> = {
value: 'hello',
getValue() {
return this.value;
},
setValue(value) {
this.value = value;
},
};
// Implementation with number
const numberContainer: Container<number> = {
value: 42,
getValue() {
return this.value;
},
setValue(value) {
this.value = value;
},
};
Common Generic Interface Patterns
Key-Value Pair
interface KeyValuePair<K, V> {
key: K;
value: V;
}
const stringNumber: KeyValuePair<string, number> = {
key: 'age',
value: 30,
};
const numberString: KeyValuePair<number, string> = {
key: 1,
value: 'first',
};
Collection Interface
interface Collection<T> {
items: T[];
add(item: T): void;
remove(item: T): boolean;
find(predicate: (item: T) => boolean): T | undefined;
getAll(): T[];
}
Repository Pattern
interface Repository<T, ID> {
findById(id: ID): T | undefined;
findAll(): T[];
save(item: T): void;
delete(id: ID): boolean;
update(id: ID, item: Partial<T>): T | undefined;
}
Result Type
interface Result<T, E> {
success: boolean;
data?: T;
error?: E;
}
// Success result
const successResult: Result<string, Error> = {
success: true,
data: 'Operation completed',
};
// Error result
const errorResult: Result<string, Error> = {
success: false,
error: new Error('Something went wrong'),
};
Generic Classes
Classes can also be generic, making them reusable for different types:
class Box<T> {
private content: T;
constructor(value: T) {
this.content = value;
}
getValue(): T {
return this.content;
}
setValue(value: T): void {
this.content = value;
}
}
// Usage
const stringBox = new Box('hello');
console.log(stringBox.getValue()); // "hello"
const numberBox = new Box(42);
console.log(numberBox.getValue()); // 42
const userBox = new Box({ name: 'Alice', age: 30 });
console.log(userBox.getValue()); // { name: "Alice", age: 30 }
Building a Generic Stack
A stack is a common data structure that follows Last-In-First-Out (LIFO):
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
clear(): void {
this.items = [];
}
}
// Usage with numbers
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3
console.log(numberStack.peek()); // 2
// Usage with strings
const stringStack = new Stack<string>();
stringStack.push('a');
stringStack.push('b');
console.log(stringStack.pop()); // "b"
Building a Generic Queue
A queue follows First-In-First-Out (FIFO):
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T | undefined {
return this.items.shift();
}
front(): T | undefined {
return this.items[0];
}
isEmpty(): boolean {
return this.items.length === 0;
}
size(): number {
return this.items.length;
}
}
// Usage
const taskQueue = new Queue<{ id: number; task: string }>();
taskQueue.enqueue({ id: 1, task: 'Send email' });
taskQueue.enqueue({ id: 2, task: 'Update database' });
taskQueue.enqueue({ id: 3, task: 'Generate report' });
while (!taskQueue.isEmpty()) {
const task = taskQueue.dequeue();
console.log(`Processing: ${task?.task}`);
}
Generic Classes with Constraints
Just like functions, classes can have type constraints:
interface Identifiable {
id: number;
}
class EntityStore<T extends Identifiable> {
private entities: Map<number, T> = new Map();
add(entity: T): void {
this.entities.set(entity.id, entity);
}
get(id: number): T | undefined {
return this.entities.get(id);
}
remove(id: number): boolean {
return this.entities.delete(id);
}
getAll(): T[] {
return Array.from(this.entities.values());
}
findBy(predicate: (entity: T) => boolean): T[] {
return this.getAll().filter(predicate);
}
}
// Usage
interface User extends Identifiable {
id: number;
name: string;
email: string;
}
const userStore = new EntityStore<User>();
userStore.add({ id: 1, name: 'Alice', email: 'alice@example.com' });
userStore.add({ id: 2, name: 'Bob', email: 'bob@example.com' });
console.log(userStore.get(1)); // { id: 1, name: "Alice", ... }
console.log(userStore.findBy((u) => u.name.startsWith('A'))); // [Alice]
Implementing Generic Interfaces
Classes can implement generic interfaces:
interface Comparable<T> {
compareTo(other: T): number;
}
class NumberWrapper implements Comparable<NumberWrapper> {
constructor(public value: number) {}
compareTo(other: NumberWrapper): number {
return this.value - other.value;
}
}
class StringWrapper implements Comparable<StringWrapper> {
constructor(public value: string) {}
compareTo(other: StringWrapper): number {
return this.value.localeCompare(other.value);
}
}
// Generic sort function using the interface
function sortItems<T extends Comparable<T>>(items: T[]): T[] {
return [...items].sort((a, b) => a.compareTo(b));
}
const numbers = [new NumberWrapper(3), new NumberWrapper(1), new NumberWrapper(2)];
const sortedNumbers = sortItems(numbers);
console.log(sortedNumbers.map((n) => n.value)); // [1, 2, 3]
const strings = [
new StringWrapper('banana'),
new StringWrapper('apple'),
new StringWrapper('cherry'),
];
const sortedStrings = sortItems(strings);
console.log(sortedStrings.map((s) => s.value)); // ["apple", "banana", "cherry"]
Generic Class with Multiple Type Parameters
class Dictionary<K, V> {
private items: Map<K, V> = new Map();
set(key: K, value: V): void {
this.items.set(key, value);
}
get(key: K): V | undefined {
return this.items.get(key);
}
has(key: K): boolean {
return this.items.has(key);
}
delete(key: K): boolean {
return this.items.delete(key);
}
keys(): K[] {
return Array.from(this.items.keys());
}
values(): V[] {
return Array.from(this.items.values());
}
entries(): [K, V][] {
return Array.from(this.items.entries());
}
}
// Usage
const userRoles = new Dictionary<number, string>();
userRoles.set(1, 'admin');
userRoles.set(2, 'editor');
userRoles.set(3, 'viewer');
console.log(userRoles.get(1)); // "admin"
console.log(userRoles.keys()); // [1, 2, 3]
console.log(userRoles.values()); // ["admin", "editor", "viewer"]
Static Members and Generics
Static members cannot use the class type parameter, but can have their own:
class Utils<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
// Instance method uses T
getAll(): T[] {
return this.items;
}
// Static method has its own type parameter
static create<U>(items: U[]): Utils<U> {
const utils = new Utils<U>();
items.forEach((item) => utils.add(item));
return utils;
}
// Static method with different type parameter
static merge<A, B>(arr1: A[], arr2: B[]): (A | B)[] {
return [...arr1, ...arr2];
}
}
const numberUtils = Utils.create([1, 2, 3]);
console.log(numberUtils.getAll()); // [1, 2, 3]
const merged = Utils.merge([1, 2], ['a', 'b']);
console.log(merged); // [1, 2, "a", "b"]
Practical Examples
Example 1: Observable Value
type Listener<T> = (value: T) => void;
class Observable<T> {
private value: T;
private listeners: Listener<T>[] = [];
constructor(initialValue: T) {
this.value = initialValue;
}
get(): T {
return this.value;
}
set(newValue: T): void {
this.value = newValue;
this.notify();
}
subscribe(listener: Listener<T>): () => void {
this.listeners.push(listener);
// Return unsubscribe function
return () => {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
};
}
private notify(): void {
this.listeners.forEach((listener) => listener(this.value));
}
}
// Usage
const counter = new Observable(0);
const unsubscribe = counter.subscribe((value) => {
console.log(`Counter changed to: ${value}`);
});
counter.set(1); // Logs: "Counter changed to: 1"
counter.set(2); // Logs: "Counter changed to: 2"
unsubscribe();
counter.set(3); // No log (unsubscribed)
Example 2: Async Cache
class AsyncCache<K, V> {
private cache: Map<K, V> = new Map();
private pending: Map<K, Promise<V>> = new Map();
constructor(private fetcher: (key: K) => Promise<V>) {}
async get(key: K): Promise<V> {
// Return cached value if exists
if (this.cache.has(key)) {
return this.cache.get(key)!;
}
// Return pending promise if already fetching
if (this.pending.has(key)) {
return this.pending.get(key)!;
}
// Fetch and cache
const promise = this.fetcher(key);
this.pending.set(key, promise);
try {
const value = await promise;
this.cache.set(key, value);
return value;
} finally {
this.pending.delete(key);
}
}
invalidate(key: K): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
}
// Usage
interface User {
id: number;
name: string;
}
const userCache = new AsyncCache<number, User>(async (id) => {
console.log(`Fetching user ${id}...`);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 100));
return { id, name: `User ${id}` };
});
async function demo() {
const user1 = await userCache.get(1); // Fetches
const user1Again = await userCache.get(1); // From cache
console.log(user1, user1Again);
}
Example 3: Result Type Class
class Result<T, E extends Error = Error> {
private constructor(
private readonly _value?: T,
private readonly _error?: E
) {}
static ok<T>(value: T): Result<T, never> {
return new Result(value);
}
static err<E extends Error>(error: E): Result<never, E> {
return new Result(undefined, error);
}
isOk(): boolean {
return this._error === undefined;
}
isErr(): boolean {
return this._error !== undefined;
}
getValue(): T {
if (this._error) {
throw new Error('Cannot get value from error result');
}
return this._value as T;
}
getError(): E {
if (!this._error) {
throw new Error('Cannot get error from success result');
}
return this._error;
}
map<U>(fn: (value: T) => U): Result<U, E> {
if (this.isOk()) {
return Result.ok(fn(this._value as T));
}
return Result.err(this._error as E);
}
mapError<F extends Error>(fn: (error: E) => F): Result<T, F> {
if (this.isErr()) {
return Result.err(fn(this._error as E));
}
return Result.ok(this._value as T);
}
}
// Usage
function divide(a: number, b: number): Result<number, Error> {
if (b === 0) {
return Result.err(new Error('Division by zero'));
}
return Result.ok(a / b);
}
const result = divide(10, 2);
if (result.isOk()) {
console.log(`Result: ${result.getValue()}`); // Result: 5
}
const errorResult = divide(10, 0);
if (errorResult.isErr()) {
console.log(`Error: ${errorResult.getError().message}`); // Error: Division by zero
}
When to Use Generic Classes vs Functions
Use Generic Classes When:
- Managing state: You need to store and manipulate data over time
- Multiple related operations: Several methods work on the same type
- Data structures: Building collections, containers, or stores
// Good: Class manages state
class UserManager<T extends { id: number }> {
private users: T[] = [];
add(user: T) {
/* ... */
}
remove(id: number) {
/* ... */
}
find(id: number) {
/* ... */
}
}
Use Generic Functions When:
- Stateless transformations: Input to output without side effects
- Single operation: One action on generic types
- Utility functions: Reusable helpers
// Good: Function is stateless
function filterByProperty<T, K extends keyof T>(items: T[], key: K, value: T[K]): T[] {
return items.filter((item) => item[key] === value);
}
Exercises
Exercise 1: Generic Linked List
Create a generic LinkedList class with the following methods:
append(value: T): Add to endprepend(value: T): Add to beginningfind(value: T): Find a nodetoArray(): Convert to array
// Test
const list = new LinkedList<number>();
list.append(1);
list.append(2);
list.prepend(0);
console.log(list.toArray()); // [0, 1, 2]
Solution
class ListNode<T> {
constructor(
public value: T,
public next: ListNode<T> | null = null
) {}
}
class LinkedList<T> {
private head: ListNode<T> | null = null;
private tail: ListNode<T> | null = null;
append(value: T): void {
const node = new ListNode(value);
if (!this.head) {
this.head = node;
this.tail = node;
} else {
this.tail!.next = node;
this.tail = node;
}
}
prepend(value: T): void {
const node = new ListNode(value, this.head);
this.head = node;
if (!this.tail) {
this.tail = node;
}
}
find(value: T): ListNode<T> | null {
let current = this.head;
while (current) {
if (current.value === value) {
return current;
}
current = current.next;
}
return null;
}
toArray(): T[] {
const result: T[] = [];
let current = this.head;
while (current) {
result.push(current.value);
current = current.next;
}
return result;
}
}
const list = new LinkedList<number>();
list.append(1);
list.append(2);
list.prepend(0);
console.log(list.toArray()); // [0, 1, 2]
Exercise 2: Generic Pool
Create a Pool class that manages a pool of reusable objects:
// Test
const pool = new Pool(() => ({ id: Math.random() }), 3);
const obj1 = pool.acquire();
const obj2 = pool.acquire();
pool.release(obj1);
const obj3 = pool.acquire(); // Should reuse obj1
Solution
class Pool<T> {
private available: T[] = [];
private inUse: Set<T> = new Set();
constructor(
private factory: () => T,
private maxSize: number
) {
// Pre-populate pool
for (let i = 0; i < maxSize; i++) {
this.available.push(factory());
}
}
acquire(): T | undefined {
if (this.available.length === 0) {
return undefined;
}
const item = this.available.pop()!;
this.inUse.add(item);
return item;
}
release(item: T): void {
if (this.inUse.has(item)) {
this.inUse.delete(item);
this.available.push(item);
}
}
getAvailableCount(): number {
return this.available.length;
}
getInUseCount(): number {
return this.inUse.size;
}
}
const pool = new Pool(() => ({ id: Math.random() }), 3);
console.log(pool.getAvailableCount()); // 3
const obj1 = pool.acquire();
const obj2 = pool.acquire();
console.log(pool.getAvailableCount()); // 1
pool.release(obj1!);
console.log(pool.getAvailableCount()); // 2
const obj3 = pool.acquire();
console.log(obj3 === obj1); // true (reused)
Exercise 3: Generic State Machine
Create a StateMachine class that manages state transitions:
// Test
type TrafficLight = 'red' | 'yellow' | 'green';
const traffic = new StateMachine<TrafficLight>('red', {
red: ['green'],
yellow: ['red'],
green: ['yellow'],
});
console.log(traffic.getState()); // "red"
console.log(traffic.canTransition('green')); // true
traffic.transition('green');
console.log(traffic.getState()); // "green"
Solution
class StateMachine<S extends string> {
private state: S;
private history: S[] = [];
constructor(
initialState: S,
private transitions: Record<S, S[]>
) {
this.state = initialState;
this.history.push(initialState);
}
getState(): S {
return this.state;
}
canTransition(newState: S): boolean {
const allowedTransitions = this.transitions[this.state];
return allowedTransitions?.includes(newState) ?? false;
}
transition(newState: S): boolean {
if (!this.canTransition(newState)) {
return false;
}
this.state = newState;
this.history.push(newState);
return true;
}
getHistory(): S[] {
return [...this.history];
}
getAllowedTransitions(): S[] {
return this.transitions[this.state] ?? [];
}
}
type TrafficLight = 'red' | 'yellow' | 'green';
const traffic = new StateMachine<TrafficLight>('red', {
red: ['green'],
yellow: ['red'],
green: ['yellow'],
});
console.log(traffic.getState()); // "red"
console.log(traffic.canTransition('green')); // true
console.log(traffic.canTransition('yellow')); // false
traffic.transition('green');
console.log(traffic.getState()); // "green"
traffic.transition('yellow');
traffic.transition('red');
console.log(traffic.getHistory()); // ["red", "green", "yellow", "red"]
Key Takeaways
- Generic interfaces: Define reusable type contracts with
interface Name<T> - Generic classes: Create type-safe data structures with
class Name<T> - Constraints work the same: Use
extendsto limit types in classes/interfaces - Implement generic interfaces: Classes can implement
Interface<T>with specific types - Static members are separate: Static methods cannot use class type parameters
- Choose wisely: Use classes for stateful code, functions for stateless transformations
Resources
| Resource | Type | Description |
|---|---|---|
| TypeScript Handbook: Generic Classes | Documentation | Official guide |
| TypeScript Handbook: Generic Constraints | Documentation | Working with constraints |
| TypeScript Deep Dive: Generics | Tutorial | Advanced patterns |
Next Lesson
Now that you can create generic classes and interfaces, let us explore TypeScript's built-in utility types that make everyday development easier.