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
- When it parses
class Box<T> { ... }, TypeScript registersTas a type available only inside that class’s instance members — fields, constructor, methods, and accessors. - At each
new Box<SomeType>(...)call site (or wherever the type argument is inferred), TypeScript substitutesSomeTypefor everyTin the members being checked, and validates the constructor arguments against that substituted signature. - 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.
- Under
--strict, generic fields are still subject tostrictPropertyInitialization: a field typedTmust be assigned in the constructor (or have a default) just like any other field. - Static members are checked in a separate, class-level scope that has no visibility into the instance-level type parameters — this is why
Tcan’t appear on astaticmember (see Common Mistakes below). - After type checking succeeds, the compiler emits ordinary JavaScript: the angle brackets, type annotations, and interfaces are stripped away entirely. The compiled
Boxclass has no runtime knowledge ofTwhatsoever — 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
extendswhenever your class relies on specific members (likeT extends Identifiable) rather than reaching foranyor 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 —Tis 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 likeTKey/TValue) once you have more than one — a loneTis fine, but two or three unnamed letters gets confusing fast. - Prefer a generic class over a class with a field typed
anyplus manual casts — that defeats the entire purpose of using generics in the first place.
Practice Exercises
- Implement a generic
Queue<T>class withenqueue(item: T): void,dequeue(): T | undefined, and a read-onlysizegetter (FIFO order, unlike theStack<T>example). Test it with a queue of strings and a queue of customOrderobjects. - Implement a generic
KeyValueStore<K, V>backed internally by aMap<K, V>, exposingset,get,has, anddeletemethods. Create one instance typedKeyValueStore<string, number>and another typedKeyValueStore<number, User>. - Extend the
Repository<T extends Identifiable, K = number>class from the examples with aremove(key: K): booleanmethod that deletes an entry and returns whether it existed, plus acountgetter 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
extendsand 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.
