Generic Classes in TypeScript


What Are Generic Classes
A generic class is a blueprint that works correctly for any type you provide at instantiation. Just like a physical storage container doesn't care whether you put books or tools inside — it holds whatever you give it — a generic class handles any type without sacrificing type safety.
TypeScript's built-in Map<K, V>, Set<T>, and Promise<T> are all generic classes under the hood. In this post, you'll learn how to write your own: declaring type parameters on classes, using multiple parameters, extending generic classes, and wiring them up with generic interfaces. By the end, you'll understand exactly how those standard-library types work — because you'll be building the same thing.
Generic Classes vs Generic Functions
Generic functions create a new type binding on each call — identity<string> and identity<number> are resolved separately at each call site. Generic classes create a binding at instantiation — new Stack<number>() fixes T to number for the lifetime of that instance. Every method on that instance then consistently uses number.
Building a Generic Class
A generic class declares its type parameter immediately after the class name. The parameter is then available in property types, method signatures, and return types throughout the class body:
class Box<T> {
private value: T;
constructor(value: T) {
this.value = value;
}
getValue(): T {
return this.value;
}
setValue(newValue: T): void {
this.value = newValue;
}
}
const numBox = new Box<number>(42);
numBox.getValue(); // number
numBox.setValue("hello"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'
const strBox = new Box("hello"); // TypeScript infers Box<string>
strBox.getValue(); // stringTypeScript can infer the type argument from the constructor argument. new Box("hello") becomes Box<string> automatically — you only need to write Box<string> explicitly when TypeScript can't infer it.
Let's build something more useful — a Stack<T> that enforces type safety on every push and pop:
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;
}
get isEmpty(): boolean {
return this.items.length === 0;
}
}
const stack = new Stack<number>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.pop(); // number | undefined — TypeScript knows the type
// Type safety across the entire interface
stack.push("hello"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'Every method that touches items uses T. The type parameter isn't just for one method — it flows through the entire class, giving you consistent type safety from push to pop.
Multiple Type Parameters
Generic classes support multiple type parameters, just like generic functions and interfaces. A key-value store is the canonical example:
class KeyValueStore<K, V> {
private store = new Map<K, V>();
set(key: K, value: V): void {
this.store.set(key, value);
}
get(key: K): V | undefined {
return this.store.get(key);
}
has(key: K): boolean {
return this.store.has(key);
}
delete(key: K): boolean {
return this.store.delete(key);
}
get size(): number {
return this.store.size;
}
}
const inventory = new KeyValueStore<string, number>();
inventory.set("apples", 5);
inventory.set("oranges", 3);
inventory.get("apples"); // number | undefined
// Different type arguments produce a different concrete class
const userCache = new KeyValueStore<number, { name: string; email: string }>();
userCache.set(1, { name: "Alice", email: "[email protected]" });
userCache.get(1); // { name: string; email: string } | undefinedK and V are fixed independently at instantiation. KeyValueStore<string, number> and KeyValueStore<number, User> are separate concrete types from the same generic class.
Extending Generic Classes
When you extend a generic class, you have three choices: pass the type parameter through to the parent, fix it to a concrete type, or add constraints.
Passing through the type parameter:
class Container<T> {
constructor(protected item: T) {}
getItem(): T {
return this.item;
}
}
class LabelledContainer<T> extends Container<T> {
constructor(
item: T,
public label: string
) {
super(item);
}
describe(): string {
return `${this.label}: ${JSON.stringify(this.item)}`;
}
}
const box = new LabelledContainer<number>(42, "The answer");
box.getItem(); // 42 — inherited, typed as number
box.describe(); // "The answer: 42"Fixing the type parameter to a concrete type:
// Specialise Container<T> for strings only
class StringContainer extends Container<string> {
toUpperCase(): string {
return this.item.toUpperCase();
}
}
const sc = new StringContainer("hello");
sc.toUpperCase(); // "HELLO"
sc.getItem(); // string — TypeScript knows the exact typeNarrowing with a constraint:
class SortedContainer<T extends number | string> extends Container<T> {
isGreaterThan(other: T): boolean {
return this.item > other;
}
}
const num = new SortedContainer(42);
num.isGreaterThan(40); // trueWhen to Fix vs Pass Through
Fix the type parameter (extends Container<string>) when creating a specialised class for a specific use case. Pass it through (extends Container<T>) when you're adding behaviour that should work for any type. If in doubt, pass it through — you can always specialise later, but removing a concrete type forces a refactor.
Generic Classes and Interfaces
A generic class can implement a generic interface by passing through its own type parameter. This is the standard pattern for the repository pattern and other data-access abstractions:
interface Repository<T> {
findById(id: number): T | undefined;
findAll(): T[];
save(entity: T): T;
delete(id: number): void;
}
class InMemoryRepository<T extends { id: number }> implements Repository<T> {
private items: T[] = [];
findById(id: number): T | undefined {
return this.items.find((item) => item.id === id);
}
findAll(): T[] {
return [...this.items];
}
save(entity: T): T {
const index = this.items.findIndex((item) => item.id === entity.id);
if (index >= 0) {
this.items[index] = entity;
} else {
this.items.push(entity);
}
return entity;
}
delete(id: number): void {
this.items = this.items.filter((item) => item.id !== id);
}
}The constraint T extends { id: number } means InMemoryRepository<T> works with any entity that has an id property. TypeScript verifies that every method signature matches the Repository<T> interface, with T substituted throughout.
interface User {
id: number;
name: string;
email: string;
}
const userRepo = new InMemoryRepository<User>();
userRepo.save({ id: 1, name: "Alice", email: "[email protected]" });
userRepo.findById(1); // User | undefined — fully typed
userRepo.findAll(); // User[] — fully typedThe T extends { id: number } syntax is a generic constraint — a way of restricting what types can be used for T. The next post in this series covers generic constraints in depth. This is just a preview of the concept; for now, read it as "T must have at least an id property of type number."
Real-World Example
Here's a type-safe event emitter — a pattern common in UI frameworks and Node.js, made fully typed with generics:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(data: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (data: Events[K]) => void
): this {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
return this;
}
off<K extends keyof Events>(
event: K,
listener: (data: Events[K]) => void
): this {
const arr = this.listeners[event];
if (arr) {
this.listeners[event] = arr.filter((l) => l !== listener) as typeof arr;
}
return this;
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(data));
}
}
// Define the full event contract for a shopping cart
interface CartEvents {
itemAdded: { productId: number; quantity: number };
itemRemoved: { productId: number };
cleared: void;
checkout: { total: number; itemCount: number };
}
const cart = new TypedEventEmitter<CartEvents>();
cart.on("itemAdded", ({ productId, quantity }) => {
// productId: number, quantity: number — fully typed
console.log(`Added ${quantity} of product ${productId}`);
});
cart.emit("itemAdded", { productId: 42, quantity: 2 }); // OK
cart.emit("itemAdded", { productId: "abc", quantity: 2 }); // Error: productId must be number
cart.emit("unknown", {}); // Error: 'unknown' is not a key of CartEventsTypeScript validates both the event name and the data shape. Emitting an unknown event or passing the wrong data type is caught at compile time — not at runtime when a user clicks a button.
Type Safety at the Call Site
The real payoff of a generic class is at every call site: your editor autocompletes valid event names, and TypeScript flags wrong data types before you run a line. That's the promise of generics applied to classes — write the structure once, get full type guarantees everywhere it's used.
Best Practices
Only add type parameters when the class genuinely handles multiple types. A UserService that only deals with User objects doesn't need generics — it just adds noise. Add <T> when the class truly needs to be reusable across types.
Let TypeScript infer where it can:
// Explicit — needed when TypeScript can't infer from the constructor
const store = new KeyValueStore<string, number>();
// Inferred — cleaner when the constructor provides enough context
const stack = new Stack([1, 2, 3]); // Stack<number[]> — inferredUse descriptive names for multiple type parameters:
// Single parameter — T is conventional and fine
class Container<T> {}
// Multiple parameters — descriptive names reduce confusion
class Repository<TEntity extends { id: TId }, TId = number> {}
class EventEmitter<TEvents extends EventMap> {}Keep the type parameter visible throughout the class. If a private helper method processes items of type T, type it as T — not as any or unknown. Losing the type information inside the class breaks the guarantee you're trying to provide.
Static Members Cannot Use Class Type Parameters
Static methods and properties belong to the class constructor itself, not to
any instance — so they have no access to the instance-level type parameter.
static create(): T is a type error. If you need a static factory method that
returns a generic type, move it to a standalone generic function, or accept
the type as a function argument.
Key Takeaways
- Generic classes declare type parameters after the class name:
class Stack<T>— the parameter is available in all instance members - TypeScript infers the type argument from the constructor:
new Stack([1, 2, 3])infersStack<number[]>automatically - Generic classes can be extended three ways: pass the parameter through, fix it to a concrete type, or narrow it with a constraint
- A generic class implements a generic interface by passing through its own type parameter:
class InMemoryRepository<T> implements Repository<T> - Static members cannot use the class-level type parameter — they belong to the class, not the instance
- Only add type parameters when the class genuinely handles multiple types; unnecessary generics add complexity without benefit
Ready for Constraints
You can now build, extend, and implement generic classes. The next post dives
into generic constraints with extends — a powerful technique that lets you
restrict what types T can be, giving you access to specific properties and
methods while keeping full type safety. Constraints are what transform
generics from a curiosity into a daily workhorse.