From Zero to AI

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:

  1. Managing state: You need to store and manipulate data over time
  2. Multiple related operations: Several methods work on the same type
  3. 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:

  1. Stateless transformations: Input to output without side effects
  2. Single operation: One action on generic types
  3. 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 end
  • prepend(value: T): Add to beginning
  • find(value: T): Find a node
  • toArray(): 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

  1. Generic interfaces: Define reusable type contracts with interface Name<T>
  2. Generic classes: Create type-safe data structures with class Name<T>
  3. Constraints work the same: Use extends to limit types in classes/interfaces
  4. Implement generic interfaces: Classes can implement Interface<T> with specific types
  5. Static members are separate: Static methods cannot use class type parameters
  6. 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.

Continue to Lesson 5.4: Utility Types