Skip to content
TypeScript ts generics 4 min read

Generic Classes

A generic class carries one or more type parameters that are bound when you create an instance, letting a single class definition serve many concrete types. Containers, caches, queues, and result wrappers are all natural fits — write the logic once and let the type parameter flow through fields, constructor arguments, and methods. This page covers declaring generic classes, constructor inference, constraints on class type parameters, the important rule that static members cannot use them, and generic methods that add their own parameters.

Declaring a generic class

The type parameter goes in angle brackets after the class name and is then visible across instance fields and methods. A type-safe Box that holds a single value is the canonical example.

class Box<T> {
  constructor(private value: T) {}

  get(): T {
    return this.value;
  }

  set(value: T): void {
    this.value = value;
  }
}

const numberBox = new Box(123);   // Box<number> — inferred from the argument
numberBox.set(456);
// numberBox.set("nope");          // ❌ Error: 'string' is not assignable to 'number'

const stringBox = new Box<string>("hi"); // explicit type argument

TypeScript infers T from the constructor argument, so new Box(123) produces a Box<number>. Every method then enforces that type, which is why set("nope") is rejected.

A reusable data structure

Generic classes really pay off for collections, where the element type must stay consistent across many methods. Here is a small typed queue.

class Queue<T> {
  private items: T[] = [];

  enqueue(item: T): void {
    this.items.push(item);
  }

  dequeue(): T | undefined {
    return this.items.shift();
  }

  get size(): number {
    return this.items.length;
  }
}

const tasks = new Queue<string>();
tasks.enqueue("build");
tasks.enqueue("test");
const next = tasks.dequeue(); // string | undefined

One Queue<T> definition gives you a fully typed Queue<string>, Queue<number>, or Queue<User> with zero duplication.

Constraints on class type parameters

Class type parameters accept extends constraints just like functions. Constrain T when the class needs to rely on certain properties or methods of its elements — for instance, a repository that requires every entity to have an id.

interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private store = new Map<number, T>();

  save(entity: T): void {
    this.store.set(entity.id, entity); // safe: T always has `id`
  }

  findById(id: number): T | undefined {
    return this.store.get(id);
  }
}

const users = new Repository<User>(); // User must have a numeric `id`

Without the constraint, entity.id would be a compile error because an arbitrary T is not known to have an id property.

Static members cannot use the class type parameter

A class type parameter is bound per instance, so static members — which belong to the class itself — cannot reference it. If a static helper needs to be generic, give it its own method-level type parameter.

class Container<T> {
  constructor(public value: T) {}

  // ❌ Error: Static members cannot reference class type parameters.
  // static defaultValue: T;

  // ✅ Static method with its own type parameter
  static of<U>(value: U): Container<U> {
    return new Container(value);
  }
}

const c = Container.of("hello"); // Container<string>

If you find yourself wanting a static member typed by T, it almost always belongs on the instance, or it needs an independent type parameter on the static method itself.

Generic methods and inheritance

A method can introduce its own type parameter in addition to the class’s, and a subclass can either fix the parent’s parameter or stay generic by passing it through.

class Collection<T> {
  protected items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  // method-level parameter `U`, independent of class `T`
  mapTo<U>(fn: (item: T) => U): U[] {
    return this.items.map(fn);
  }
}

// Subclass fixes T = string
class StringCollection extends Collection<string> {
  join(sep = ", "): string {
    return this.items.join(sep);
  }
}

// Subclass stays generic, forwarding T
class LoggedCollection<T> extends Collection<T> {
  add(item: T): void {
    console.log("adding", item);
    super.add(item);
  }
}

Class vs interface vs function generics

Use caseBest tool
Stateful container with behaviorGeneric class
Pure shape contract, no implementationGeneric interface
One-off transformation, no stateGeneric function
Shared logic across subtypesGeneric class + constraint

Best Practices

  • Let the constructor infer the type argument when possible; annotate only when it cannot.
  • Add extends constraints when the class relies on specific members of its elements.
  • Never try to type a static member with the class type parameter; use a static method’s own parameter.
  • Give methods their own type parameters when their type is independent of the instance’s.
  • Decide deliberately whether a subclass fixes the parent’s parameter or forwards it.
  • Keep fields private/protected and expose typed methods to preserve the generic invariants.
  • Prefer a generic interface when you only need a contract with no shared implementation.
Last updated June 29, 2026
Was this helpful?