Generic Classes in TypeScript
Generic classes let you build stateful, object-oriented abstractions that work correctly with any type. Like generic functions and interfaces, they declare type parameters that are filled in at construction time — giving you full type safety throughout every method call.
Declaring a Generic Class
Place the type parameter list directly after the class name. Every instance method, constructor parameter, and property can reference those parameters.
class Box<T> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
setValue(newValue: T): void {
this.value = newValue;
}
map<U>(fn: (value: T) => U): Box<U> {
return new Box(fn(this.value));
}
}
const numBox = new Box(42); // Box<number>
const strBox = new Box('hello'); // Box<string>
console.log(numBox.getValue()); // 42
const doubled = numBox.map(n => n * 2); // Box<number>
const asStr = numBox.map(n => `${n}`); // Box<string>
// numBox.setValue('wrong'); // ❌ string is not assignable to numberType Inference at Construction
TypeScript infers the type argument from the constructor arguments, just like with generic functions. You rarely need to write the type argument explicitly.
// Inferred
const a = new Box(100); // Box<number>
const b = new Box('world'); // Box<string>
const c = new Box([1, 2, 3]); // Box<number[]>
// Explicit — same result, more verbose
const d = new Box<number>(100);
// Useful when the constructor cannot infer (e.g. empty collections)
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]; }
get size(): number { return this.items.length; }
isEmpty(): boolean { return this.items.length === 0; }
}
const stack = new Stack<number>(); // Must be explicit — no constructor arg
stack.push(1);
stack.push(2);
console.log(stack.pop()); // 2T, so you must write new MyClass<T>() explicitly.Generic Class: Queue
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T {
if (this.isEmpty()) throw new Error('Queue is empty');
return this.items.shift()!;
}
peek(): T {
if (this.isEmpty()) throw new Error('Queue is empty');
return this.items[0];
}
isEmpty(): boolean {
return this.items.length === 0;
}
get size(): number {
return this.items.length;
}
toArray(): T[] {
return [...this.items];
}
}
const q = new Queue<string>();
q.enqueue('first');
q.enqueue('second');
q.enqueue('third');
console.log(q.dequeue()); // 'first'
console.log(q.size); // 2Generic Classes Implementing Generic Interfaces
A generic class can implement a generic interface. The class can forward its own type parameter, specialise it with a concrete type, or introduce new parameters.
interface Collection<T> {
add(item: T): void;
remove(item: T): boolean;
contains(item: T): boolean;
toArray(): T[];
readonly size: number;
}
// Forward T to the interface
class TypedSet<T> implements Collection<T> {
private items = new Set<T>();
add(item: T): void { this.items.add(item); }
remove(item: T): boolean { return this.items.delete(item); }
contains(item: T): boolean { return this.items.has(item); }
toArray(): T[] { return Array.from(this.items); }
get size(): number { return this.items.size; }
}
const numSet = new TypedSet<number>();
numSet.add(1);
numSet.add(2);
numSet.add(1); // duplicate — ignored by Set
console.log(numSet.size); // 2Generic Class: LinkedList
class ListNode<T> {
constructor(
public value: T,
public next: ListNode<T> | null = null
) {}
}
class LinkedList<T> {
private head: ListNode<T> | null = null;
private _size = 0;
prepend(value: T): void {
const node = new ListNode(value, this.head);
this.head = node;
this._size++;
}
append(value: T): void {
const node = new ListNode(value);
if (!this.head) {
this.head = node;
} else {
let current = this.head;
while (current.next) current = current.next;
current.next = node;
}
this._size++;
}
toArray(): T[] {
const result: T[] = [];
let current = this.head;
while (current) {
result.push(current.value);
current = current.next;
}
return result;
}
get size(): number { return this._size; }
}
const list = new LinkedList<number>();
list.append(1);
list.append(2);
list.prepend(0);
console.log(list.toArray()); // [0, 1, 2]Static Members and Type Parameters
Static methods and properties cannot use the class's instance type parameters — they belong to the class itself, not to instances. Use separate type parameters on the static method if needed.
class Container<T> {
constructor(private items: T[]) {}
// Static factory — cannot reference T from class scope
// Instead, declare a local type parameter <U>
static of<U>(...items: U[]): Container<U> {
return new Container(items);
}
static empty<U>(): Container<U> {
return new Container<U>([]);
}
map<U>(fn: (item: T) => U): Container<U> {
return new Container(this.items.map(fn));
}
filter(pred: (item: T) => boolean): Container<T> {
return new Container(this.items.filter(pred));
}
toArray(): T[] { return [...this.items]; }
}
const nums = Container.of(1, 2, 3, 4); // Container<number>
const evens = nums.filter(n => n % 2 === 0); // Container<number>
const strs = nums.map(n => n.toString()); // Container<string>
console.log(strs.toArray()); // ['1', '2', '3', '4']Generic Class: Observable / EventBus
type Listener<T> = (value: T) => void;
class Observable<T> {
private listeners = new Set<Listener<T>>();
private _value: T;
constructor(initial: T) {
this._value = initial;
}
get value(): T {
return this._value;
}
set(newValue: T): void {
this._value = newValue;
this.notify(newValue);
}
update(fn: (current: T) => T): void {
this.set(fn(this._value));
}
subscribe(listener: Listener<T>): () => void {
this.listeners.add(listener);
// Return an unsubscribe function
return () => this.listeners.delete(listener);
}
private notify(value: T): void {
this.listeners.forEach(listener => listener(value));
}
}
const counter = new Observable(0); // Observable<number>
const unsub = counter.subscribe(n => console.log('count:', n));
counter.set(1); // logs: count: 1
counter.update(n => n + 1); // logs: count: 2
unsub(); // remove listener
counter.set(3); // no logExtending Generic Classes
class Animal<TSound extends string> {
constructor(
public name: string,
protected sound: TSound
) {}
speak(): string {
return `${this.name} says ${this.sound}`;
}
}
// Specialise: Dog always uses 'woof'
class Dog extends Animal<'woof'> {
constructor(name: string) {
super(name, 'woof');
}
fetch(item: string): string {
return `${this.name} fetches the ${item}!`;
}
}
const d = new Dog('Rex');
console.log(d.speak()); // 'Rex says woof'
console.log(d.fetch('ball')); // 'Rex fetches the ball!'
// Forward T to parent — keeps the class generic
class PetStore<T extends Animal<string>> {
private pets: T[] = [];
add(pet: T): void { this.pets.push(pet); }
findByName(name: string): T | undefined {
return this.pets.find(p => p.name === name);
}
}Generic Classes with Constraints
interface Serializable {
serialize(): string;
toJSON(): object;
}
// T must implement Serializable
class Repository<T extends Serializable> {
private store = new Map<string, T>();
save(key: string, entity: T): void {
this.store.set(key, entity);
console.log(`Saved: ${entity.serialize()}`);
}
load(key: string): T | undefined {
return this.store.get(key);
}
exportAll(): object[] {
return Array.from(this.store.values()).map(e => e.toJSON());
}
}Comparison: When to Use Each Approach
Need | Use |
|---|---|
Reusable logic over any type | Generic function |
Reusable data contract / shape | Generic interface or type alias |
Stateful object that holds/transforms typed data | Generic class |
External library contract you cannot change | Generic interface (declaration merging) |
Union or conditional logic | Generic type alias |
Quick Reference
class Foo<T> — declare a generic class
new Foo(value) — TypeScript infers T from constructor arguments
new Foo<T>() — explicit type arg needed when constructor has no typed args
Static members cannot reference the class T — add a local type param instead
A generic class can implement a generic interface: class Foo<T> implements Bar<T>
Extend a generic class with class Child<T> extends Parent<T>
Add constraints on T: class Foo<T extends SomeInterface>
extends to unlock property access on generic types.