TypeScriptGeneric Classes

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.

TS
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 number
Type 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.

TS
// 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()); // 2
Note
When the constructor takes no arguments TypeScript cannot infer T, so you must write new MyClass<T>() explicitly.
Generic Class: Queue

TS
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);      // 2
Generic 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.

TS
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); // 2
Generic Class: LinkedList

TS
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.

TS
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']
Warning
TypeScript will error if you try to reference the class type parameter inside a static method or property. Always add a fresh type param on the static member itself.
Generic Class: Observable / EventBus

TS
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 log
Extending Generic Classes

TS
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

TS
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>

Success
You can now build type-safe data structures and object-oriented abstractions with generic classes. Next, learn how to constrain type parameters with extends to unlock property access on generic types.