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.