Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Generic Classes in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Introduction to Generics in TypeScript
TypeScript
9m
May 17, 2026

Introduction to Generics in TypeScript

Generics are the feature that separates TypeScript beginners from intermediate developers. They let you write a function or data structure once and have it work correctly across many types — with full type safety at every call site. This post starts from first principles.

#Generics#Type Parameters+5
Generic Functions and Type Inference in TypeScript
TypeScript
8m
May 24, 2026

Generic Functions and Type Inference in TypeScript

TypeScript's type inference engine is smart — most of the time it figures out generic type parameters without any help. But knowing *how* inference works lets you write generics that always infer correctly, and knowing *when* it falls short lets you provide explicit type arguments at exactly the right moments.

#Generic Functions#Type Inference+5
Generic Interfaces and Types in TypeScript
TypeScript
8m
May 31, 2026

Generic Interfaces and Types in TypeScript

Generic interfaces and type aliases take the power of type parameters beyond individual functions. They let you describe reusable data structures — paginated responses, result types, tree nodes, repositories — that work correctly for any data type, with full compile-time guarantees.

#Generic Interfaces#Generic Types+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Are Generic Classes
  • Building a Generic Class
  • Multiple Type Parameters
  • Extending Generic Classes
  • Generic Classes and Interfaces
  • Real-World Example
  • Best Practices
  • Key Takeaways
TypeScript

Generic Classes in TypeScript

June 7, 2026•9 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript generic classes for reusable, type-safe class patterns
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(); // string

TypeScript 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 } | undefined

K 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 type

Narrowing 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); // true

When 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 typed

The 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 CartEvents

TypeScript 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[]> — inferred

Use 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]) infers Stack<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.