TypeScript Generic Classes Recap

A generic class lets you write a class once and reuse it safely for many different types, instead of duplicating the class per type or falling back to any. Instead of hardcoding a type inside the class body, you declare a type parameter on the class itself, and every place that type parameter appears inside the class is filled in with whatever concrete type you use when you instantiate it. This recap pulls together everything you need to know about generic classes: declaring them, constraining them, giving them defaults, mixing them with interfaces and inheritance, and the mistakes almost everyone makes the first time.

Overview: How Generic Classes Work

When you write class Box<T> { ... }, T is a placeholder type that is bound the moment you construct an instance: new Box<number>(5) substitutes number for every T inside that instance’s type. TypeScript’s type checker treats T as an opaque type throughout the class body — it doesn’t know what T actually is, so it won’t let you do anything with a value of type T that isn’t guaranteed to work for every possible type (no arithmetic, no property access, no assuming it has a .length) unless you add a constraint.

Under the hood, this is purely a compile-time mechanism. TypeScript uses generics to check that your code is internally consistent (the value you put into a Box<string> is always a string when you take it back out), but by the time the code is compiled to JavaScript, all type parameters are erased. There is no runtime trace of T at all — a compiled Box class has no idea whether it was created as a Box<number> or a Box<string>. This is why you cannot do things like if (value instanceof T) inside a generic class: T simply doesn’t exist at runtime.

Generic classes also work with TypeScript’s structural type system: two instances are compatible if their shapes line up, regardless of which type argument produced that shape. A Box<string> and a hand-written object with a matching getContents(): string method are structurally interchangeable as far as the type checker is concerned.

Syntax

class ClassName<T> {
  private field: T;
  constructor(value: T) { this.field = value; }
}

class ClassName<T extends Constraint> { /* T must satisfy Constraint */ }
class ClassName<T = DefaultType>      { /* T defaults if omitted */ }
class ClassName<T, U>                 { /* multiple type parameters */ }

class Derived<T> extends Base<T> { /* generic inheritance */ }
class Impl<T> implements SomeInterface<T> { /* generic interface */ }
  • <T> — declares one or more type parameters right after the class name, before the constructor and body.
  • T extends Constraint — restricts what T can be; inside the class, the compiler now knows T has at least the shape of Constraint.
  • T = DefaultType — if the caller writes new ClassName() with no type argument, T becomes DefaultType (and if nothing can be inferred either, TypeScript falls back to it).
  • A class can have several type parameters (<K, V>), and methods inside the class can introduce their own additional type parameters that are independent of the class’s own.
  • The type parameter is available anywhere in the class body: field types, method parameter types, return types, and even in extends/implements clauses — but not in static members (more on that below).

Examples

Example 1: A basic generic class

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

Notice numberBox explicitly supplies <number>, while stringBox relies on type argument inference: because the constructor is called with "hello", TypeScript infers T = string without you writing it out. Once inferred, T is locked in for that instance — calling stringBox.setContents(42) would be a compile error.

Example 2: Constraining the type parameter

interface HasId {
  id: number;
}

class Repository<T extends HasId> {
  private items: T[] = [];

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

  findById(id: number): T | undefined {
    return this.items.find(item => item.id === id);
  }

  getAll(): readonly T[] {
    return this.items;
  }
}

interface User extends HasId {
  name: string;
}

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

console.log(userRepo.findById(2));
console.log(userRepo.getAll().length);

Output:

{ id: 2, name: 'Grace' }
2

The constraint T extends HasId means Repository only accepts types that have at least an id: number property, and it lets findById compare item.id safely — something that would be a compile error on a bare, unconstrained T. Any type that structurally satisfies HasId can be used, not just types that explicitly declare extends HasId.

Example 3: Default type parameter, generic interface, and a generic method

interface Container<T> {
  value: T;
}

class Stack<T = string> implements Container<T[]> {
  private items: T[] = [];

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

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

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

  map<U>(fn: (item: T) => U): U[] {
    return this.items.map(fn);
  }
}

const stack = new Stack<number>();
stack.push(10);
stack.push(20);
stack.push(30);

const doubled = stack.map(n => n * 2);
console.log(doubled);
console.log(stack.value);

const defaultStack = new Stack();
defaultStack.push("typescript");
console.log(defaultStack.value);

Output:

[ 20, 40, 60 ]
[ 10, 20, 30 ]
[ 'typescript' ]

This example combines four ideas at once. Stack<T = string> has a default, so new Stack() (no type argument, no constructor argument to infer from) becomes Stack<string>. Stack implements Container<T[]>, showing that a class’s own type parameter can be plugged into another generic type in its implements clause. Finally, map<U> introduces a second, independent type parameter scoped only to that method — it doesn’t need to be declared on the class because it’s fresh for every call and inferred from the callback you pass in.

Under the Hood

When the compiler encounters new Stack<number>(), it substitutes number for every occurrence of T in Stack‘s member signatures for that instance and checks all subsequent usages against that substituted type. This happens purely in the type checker’s internal model — it never touches the generated JavaScript. If you compile the examples above and open the output .js file, you’ll find plain classes with no angle brackets, no T, and no runtime type information whatsoever; class Box { constructor(value) { this.contents = value; } ... }. This is why you can’t write new T() or value instanceof T inside a generic class — there is nothing left at runtime to instantiate or check against. If you need runtime knowledge of the type, you must pass it explicitly, e.g. as a constructor parameter (a “class value” or factory function).

Common Mistakes

Mistake 1: Using the class type parameter on a static member

Static members belong to the class itself, not to any particular instance — but a class’s type parameter is only known once an instance is created. So this fails:

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

TypeScript reports: Static members cannot reference class type parameters.ts(2302). Fix it by giving the static member its own, independent type parameter on a method instead of trying to reuse the class’s:

class Container<T> {
  private value: T;

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

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

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

Output:

Container { value: 'hi' }

The static create method declares its own <T>, which is scoped to that single method call and has nothing to do with any particular instance’s type parameter.

Mistake 2: Forgetting to constrain a type parameter before using it

An unconstrained T could be anything, so the compiler refuses to let you use operators that only work on specific types:

class Adder<T> {
  add(a: T, b: T): T {
    return a + b;
  }
}

This produces Operator '+' cannot be applied to types 'T' and 'T'.ts(2365), because T might be an object, a boolean, anything — not necessarily something addable. Constrain T to the types the operation actually supports:

class Adder<T extends number> {
  add(a: T, b: T): T {
    return (a + b) as T;
  }
}

const adder = new Adder<number>();
console.log(adder.add(2, 3));

Output:

5

With T extends number, the compiler knows a and b support numeric addition. The result of a + b widens to number, so a cast back to T is still needed if T could be a narrower numeric literal type, but the operation itself is now legal.

Best Practices

  • Add a constraint (extends) as soon as your generic class needs to call a method or read a property on values of type T — don’t reach for any as a shortcut.
  • Use a default type parameter (T = string) when there’s a sensible, commonly-used type so callers aren’t forced to write <SomeType> every time.
  • Let TypeScript infer type arguments from constructor arguments whenever possible; only write the type argument explicitly when there’s nothing to infer from or the inferred type would be wrong.
  • Give methods their own type parameters (like map<U>) instead of trying to reuse or extend the class’s type parameter for something unrelated to instance state.
  • Remember type erasure: never rely on generic type information being available at runtime; pass constructors, factory functions, or discriminant values explicitly if runtime behavior needs to vary by type.
  • Prefer interface definitions for the shapes you constrain against (T extends HasId) so the constraint is reusable and documents intent clearly.

Practice Exercises

  • Write a generic class Pair<K, V> with a constructor taking a key of type K and a value of type V, plus methods getKey(): K and getValue(): V. Instantiate it as Pair<string, number>.
  • Write a generic class Queue<T> with enqueue(item: T): void and dequeue(): T | undefined backed by an internal array. Add a constraint so it only accepts objects with a timestamp: number property, and add a method sortByTimestamp(): void that sorts the internal array in place.
  • Take the broken Adder<T> class from the Common Mistakes section and instead of constraining T extends number, make it work for both number and string by writing two separate method overloads for add. Verify both adder.add(1, 2) and adder.add("a", "b") type-check.

Summary

  • A generic class declares one or more type parameters in angle brackets after its name, e.g. class Box<T>, which are substituted with a concrete type at construction time.
  • Type parameters are erased at runtime — the compiled JavaScript has no trace of them, so you can’t use them for runtime checks like instanceof.
  • Use T extends Constraint to restrict what a type parameter can be and unlock operations the compiler otherwise disallows on a bare T.
  • Use T = DefaultType to give callers a sensible fallback when they omit the type argument.
  • Static members cannot reference a class’s own type parameters; give static methods their own type parameters instead.
  • Methods inside a generic class can introduce independent type parameters of their own, separate from the class’s type parameter list.