Generic Classes & Interfaces

intermediate generics classes interfaces data-structures

In simple language, generic functions let one function work with many types. Generic classes and interfaces do the same thing for whole objects — a Stack<number> and a Stack<string> share one definition but stay totally type-safe.

Generic interfaces

The type parameter goes right after the interface name. Inside, we can use it anywhere a type goes.

interface ApiResponse<T> {
  data: T;
  status: number;
  error?: string;
}

const r1: ApiResponse<string> = { data: "ok", status: 200 };
const r2: ApiResponse<{ id: number }> = { data: { id: 1 }, status: 200 };

The classic example: an interface for any container.

interface Box<T> {
  contents: T;
}

const numBox: Box<number> = { contents: 42 };
const strBox: Box<string> = { contents: "hi" };

Generic classes

Same pattern — type parameter after the class name, available throughout the class body.

class Stack<T> {
  private items: T[] = [];
  push(item: T) { this.items.push(item); }
  pop(): T | undefined { return this.items.pop(); }
  peek(): T | undefined { return this.items[this.items.length - 1]; }
  get size() { return this.items.length; }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push("two"); // Error — must be number

const strStack = new Stack<string>();
strStack.push("hi"); // OK

Multiple type parameters

A Map<K, V> is the obvious one. Let’s write our own simplified version.

class Dictionary<K extends string | number, V> {
  private store: Record<string, V> = {};
  set(key: K, value: V) { this.store[String(key)] = value; }
  get(key: K): V | undefined { return this.store[String(key)]; }
}

const d = new Dictionary<string, number>();
d.set("age", 28);
const a = d.get("age"); // a: number | undefined

Implementing a generic interface

A class can implement a generic interface — and either lock the type in or stay generic itself.

interface Repository<T> {
  find(id: number): T | null;
  save(item: T): void;
}

// locked-in: user-specific repo
class UserRepo implements Repository<{ id: number; name: string }> {
  find(id: number) { return { id, name: "M" }; }
  save(item: { id: number; name: string }) { /* ... */ }
}

// stays generic: works for any T
class InMemoryRepo<T extends { id: number }> implements Repository<T> {
  private items: T[] = [];
  find(id: number) { return this.items.find(i => i.id === id) ?? null; }
  save(item: T) { this.items.push(item); }
}

Extending generic classes

A subclass can pass through the generic, lock it down, or add its own.

class Queue<T> {
  protected items: T[] = [];
  enqueue(x: T) { this.items.push(x); }
  dequeue(): T | undefined { return this.items.shift(); }
}

// lock T to string
class StringQueue extends Queue<string> {
  joinAll() { return this.items.join(","); }
}

// pass T through
class LoggedQueue<T> extends Queue<T> {
  enqueue(x: T) {
    console.log("adding", x);
    super.enqueue(x);
  }
}

Default type parameters

Just like with functions, classes and interfaces can have defaults.

interface Result<T = unknown, E = Error> {
  ok: boolean;
  value?: T;
  error?: E;
}

const r: Result = { ok: false, error: new Error("boom") };
// T defaults to unknown, E to Error

Common interview prompt

“Build a typed event emitter where .on('foo', handler) only accepts handlers matching the payload of foo.” That’s a generic interface keyed by event name plus a K extends keyof Events constraint — combines everything from the last three notes.

interface Events {
  login: { userId: number };
  logout: { reason: string };
}

class Emitter<E> {
  on<K extends keyof E>(event: K, handler: (payload: E[K]) => void) {
    /* register */
  }
}

const e = new Emitter<Events>();
e.on("login", (p) => p.userId);  // p: { userId: number }
e.on("logout", (p) => p.reason); // p: { reason: string }

Once we’re comfortable here, the rest of TypeScript’s advanced types — utility types, conditional types, mapped types — all build on this same foundation.