TypeScript Generic Classes

A generic class is a class parameterized over one or more types, letting you write a single class definition that works safely with many different data types without losing type checking. Instead of writing a separate NumberBox, StringBox, and UserBox class, you write one Box<T> class and let the type parameter T stand in for whatever type the caller chooses when they create an instance.

Generic classes are the backbone of most real-world data structures and abstractions: stacks, queues, linked lists, caches, repositories, event emitters, and API clients are almost always generic, because their behavior (push, pop, get, set) stays identical regardless of what type of data they hold. TypeScript’s generics let the compiler track that type through every method call, so you get compile-time safety instead of relying on runtime checks, casts, or any.

Overview / How It Works

When you write class Box<T> { ... }, you are declaring both a runtime value (the class itself, usable with new) and a family of types (Box<number>, Box<string>, Box<User>, and so on). Inside the class body, T behaves like any other type: you can use it as a parameter type, a return type, a field type, or the type of a local variable inside a method. Every occurrence of T inside that class body refers to the exact same type for a given instance.

When you instantiate the class with new Box<number>(42), you are supplying a type argument that the compiler substitutes for T everywhere inside the class for that instance’s type. TypeScript can often infer the type argument from the constructor call instead of you writing it explicitly — new Box(42) infers Box<number> automatically because the constructor parameter is typed T and you passed a number.

TypeScript’s type system is structural, not nominal: a generic class’s methods accept anything whose shape matches the substituted parameter type, not only objects created by a specific constructor. And crucially, this entire mechanism is a compile-time-only construct. Once the compiler has verified everything lines up, it erases all type information — type parameters, annotations, interfaces — and emits plain JavaScript. This is called type erasure. The compiled Box class at runtime has no idea what T ever was; there is no typeof T or instanceof T you can perform inside a generic class, because by the time the code runs, T simply does not exist anymore.

Syntax

class ClassName<T, K extends object = Record<string, unknown>> {
  private field: T;

  constructor(value: T) {
    this.field = value;
  }

  method(): T {
    return this.field;
  }
}
Part Meaning
class ClassName<T> Declares the class and its type parameter list. T is just an identifier — convention favors short, capitalized names.
extends / implements A generic class may extend another generic class or implement a generic interface, either forwarding its own type parameter or fixing a concrete type.
<T, K extends object> Multiple type parameters, evaluated left to right. extends here adds a constraint, restricting what types are allowed for K.
= Record<string, unknown> A default type argument, used when the caller omits that parameter at the instantiation site.
new ClassName<Type>(...) Supplies the type argument explicitly. Often optional — TypeScript infers it from the constructor arguments when it can.

Common type parameter conventions

Letter Typical meaning
T Type (generic, no specific role)
K / V Key / Value, as in maps and dictionaries
E Element, as in a collection’s item type
U A second, unrelated type when T is already used

Examples

Example 1: A simple generic Box

class Box<T> {
  private contents: T;

  constructor(value: T) {
    this.contents = value;
  }

  getContents(): T {
    return this.contents;
  }

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

const numberBox = new Box<number>(42);
console.log(numberBox.getContents());

const stringBox = new Box("hello");
console.log(stringBox.getContents());

stringBox.setContents("world");
console.log(stringBox.getContents());

Output:

42
hello
world

numberBox explicitly supplies <number>, while stringBox lets TypeScript infer T = string from the constructor argument "hello". From that point on, getContents() and setContents() are locked to that specific type for that instance — calling stringBox.setContents(99) would be a compile error.

Example 2: A generic Stack with realistic methods

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 numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop());
console.log(numberStack.peek());
console.log(numberStack.size);

interface Task {
  id: number;
  title: string;
}

const taskStack = new Stack<Task>();
taskStack.push({ id: 1, title: "Write lesson" });
taskStack.push({ id: 2, title: "Review PR" });
console.log(taskStack.pop()?.title);

Output:

3
2
2
Review PR

The same Stack<T> definition backs both a stack of numbers and a stack of Task objects, with full type safety in each case — numberStack.push("nope") or taskStack.pop()?.nonsense would both fail to compile. Note new Stack<number>() requires an explicit type argument here because the constructor takes no arguments to infer from.

Example 3: Multiple type parameters, a constraint, and a default

interface Identifiable {
  id: number;
}

class Repository<T extends Identifiable, K = number> {
  private items = new Map<K, T>();

  add(key: K, item: T): void {
    this.items.set(key, item);
  }

  getById(key: K): T | undefined {
    return this.items.get(key);
  }

  findByPredicate(predicate: (item: T) => boolean): T[] {
    return Array.from(this.items.values()).filter(predicate);
  }
}

interface User extends Identifiable {
  name: string;
  active: boolean;
}

const userRepo = new Repository<User>();
userRepo.add(1, { id: 1, name: "Ada", active: true });
userRepo.add(2, { id: 2, name: "Grace", active: false });

console.log(userRepo.getById(1)?.name);
console.log(userRepo.findByPredicate(u => u.active).map(u => u.name));

Output:

Ada
[ 'Ada' ]

Repository<T extends Identifiable, K = number> combines a constraint (T must have an id: number) with a default type argument for the key (K defaults to number if omitted). Writing new Repository<User>() only supplies T; K falls back to its default of number, so add and getById expect numeric keys without you writing Repository<User, number> every time.

Under the Hood: What the Compiler Actually Does

  1. When it parses class Box<T> { ... }, TypeScript registers T as a type available only inside that class’s instance members — fields, constructor, methods, and accessors.
  2. At each new Box<SomeType>(...) call site (or wherever the type argument is inferred), TypeScript substitutes SomeType for every T in the members being checked, and validates the constructor arguments against that substituted signature.
  3. Because typing is structural, any value whose shape matches the substituted type is accepted — you don’t need a specific class hierarchy, just matching properties and methods.
  4. Under --strict, generic fields are still subject to strictPropertyInitialization: a field typed T must be assigned in the constructor (or have a default) just like any other field.
  5. Static members are checked in a separate, class-level scope that has no visibility into the instance-level type parameters — this is why T can’t appear on a static member (see Common Mistakes below).
  6. After type checking succeeds, the compiler emits ordinary JavaScript: the angle brackets, type annotations, and interfaces are stripped away entirely. The compiled Box class has no runtime knowledge of T whatsoever — you cannot ask a generic class at runtime what type argument it was created with unless you store that information yourself, explicitly, as a normal value.

Common Mistakes

Mistake 1: Referencing a type parameter on a static member

class Container<T> {
  static defaultValue: T;
}

This fails to compile with an error along the lines of Static members cannot reference class type parameters. Static members belong to the class itself, not to any particular instance, so there is no single T they could refer to — a class can be instantiated as Container<number> and Container<string> at the same time, and both would share the same static members. The fix is to give the static member its own, independent type parameter on a generic method instead:

class Container<T> {
  private value: T;

  constructor(value: T) {
    this.value = value;
  }

  static create<U>(value: U): Container<U> {
    return new Container<U>(value);
  }
}

const c = Container.create("hi");
console.log(c);

Here create declares its own type parameter U, scoped to the static method itself rather than to the class, so it type-checks cleanly and still returns a properly typed Container<string>.

Mistake 2: Omitting the type argument and getting an implicit unknown

class EventEmitter<T> {
  private listeners: Array<(payload: T) => void> = [];

  on(listener: (payload: T) => void): void {
    this.listeners.push(listener);
  }

  emit(payload: T): void {
    this.listeners.forEach(listener => listener(payload));
  }
}

const emitter = new EventEmitter();
emitter.on(payload => console.log(payload.toUpperCase()));

Because EventEmitter has no constructor parameters that use T, TypeScript has nothing to infer the type argument from, so it silently falls back to unknown for T. Inside on, payload is then typed unknown, and tsc reports something like Property 'toUpperCase' does not exist on type 'unknown'. The fix is to supply the type argument explicitly at construction time, since there’s no other clue for the compiler to use:

class EventEmitter<T> {
  private listeners: Array<(payload: T) => void> = [];

  on(listener: (payload: T) => void): void {
    this.listeners.push(listener);
  }

  emit(payload: T): void {
    this.listeners.forEach(listener => listener(payload));
  }
}

const emitter = new EventEmitter<string>();
emitter.on(payload => console.log(payload.toUpperCase()));
emitter.emit("hello");

With EventEmitter<string> declared up front, payload is correctly typed as string everywhere, and toUpperCase() is valid. This prints HELLO.

Best Practices

  • Constrain type parameters with extends whenever your class relies on specific members (like T extends Identifiable) rather than reaching for any or unsafe casts.
  • Give a type parameter a sensible default (K = number) when a class is usually used with one particular type argument, so most call sites can omit it.
  • Let TypeScript infer type arguments from constructor calls whenever possible; only add an explicit <Type> when inference would be ambiguous, or when the constructor takes no arguments that mention the type parameter.
  • Never try to write new T() inside a generic class — T is erased at runtime and might not even be a constructor. Pass a factory function or a class reference as an explicit parameter instead if you need that capability.
  • Keep static members free of the class’s own type parameters; give a static method its own type parameter (like static create<U>(...)) if it needs to be generic.
  • Use multiple, clearly named type parameters (K, V, or full names like TKey/TValue) once you have more than one — a lone T is fine, but two or three unnamed letters gets confusing fast.
  • Prefer a generic class over a class with a field typed any plus manual casts — that defeats the entire purpose of using generics in the first place.

Practice Exercises

  • Implement a generic Queue<T> class with enqueue(item: T): void, dequeue(): T | undefined, and a read-only size getter (FIFO order, unlike the Stack<T> example). Test it with a queue of strings and a queue of custom Order objects.
  • Implement a generic KeyValueStore<K, V> backed internally by a Map<K, V>, exposing set, get, has, and delete methods. Create one instance typed KeyValueStore<string, number> and another typed KeyValueStore<number, User>.
  • Extend the Repository<T extends Identifiable, K = number> class from the examples with a remove(key: K): boolean method that deletes an entry and returns whether it existed, plus a count getter that returns how many items are stored.

Summary

  • A generic class is parameterized with one or more type parameters (class Box<T>), letting one definition serve many concrete types safely.
  • Type arguments can be supplied explicitly (new Box<number>(...)) or inferred from constructor arguments when possible.
  • Type parameters can be constrained with extends and given defaults with =, and a class can declare multiple type parameters.
  • Static members cannot reference a class’s instance-level type parameters; give them their own type parameters instead.
  • Type erasure means all generic information disappears from the compiled JavaScript — generics exist purely to help the compiler catch mistakes before runtime.
  • Omitting a type argument that can’t be inferred silently falls back to unknown, which often surfaces as confusing downstream errors — supply it explicitly when in doubt.