TypeScript Generic Classes

In TypeScript, Generic Classes are a powerful tool that allows you to define a class structure without committing to a specific data type for its properties or methods until the class is actually instantiated. Think of them as templates: you define the logic once, and then you "fill in" the specific types (like string, number, or a custom Interface) when you create an object from that class.

Developer Tip: Think of generics as "type variables." Just as you pass arguments to a function, you pass types to a generic class to customize how it behaves.

 

What are Generic Classes?

A generic class is a class that can work with a variety of types while still maintaining full type safety. Without generics, you might be tempted to use the any type to handle different kinds of data. However, using any effectively turns off TypeScript's type checking, leading to potential runtime errors. Generics solve this by allowing the class to "capture" the type provided by the user, ensuring that the compiler knows exactly what kind of data is being handled at all times.

Common Mistake: Beginners often use any when they want a class to be flexible. This defeats the purpose of TypeScript. Use a generic <T> instead to keep your code flexible AND safe.

 

Syntax of Generic Classes

The syntax involves placing a type parameter—usually represented by the letter T—inside angle brackets (<T>) immediately after the class name. This T then becomes a placeholder that you can use throughout the class body.

class MyComponent<T> {
  content: T;

  constructor(initialContent: T) {
    this.content = initialContent;
  }

  getContent(): T {
    return this.content;
  }
}
  • <T>: This is the type parameter. While T is the convention (standing for "Type"), you could name it ItemType or Payload.
  • content: T: This property will strictly match whatever type is passed in during instantiation.
  • getContent(): T: The method is guaranteed to return that same specific type.
Best Practice: Use descriptive names for type parameters if T isn't clear enough, especially when using multiple generics (e.g., <TKey, TValue>).

 

Example of a Generic Class

1. Generic Box Class

Imagine you need a container to hold data, but you don't know yet if that data will be a user object, a simple number, or an array of strings. A generic Box class handles this perfectly:

class Box<T> {
  private value: T;

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

  getValue(): T {
    return this.value;
  }

  setValue(newValue: T): void {
    this.value = newValue;
  }
}

// Usage with a number
const numberBox = new Box<number>(404);
console.log(numberBox.getValue()); // Output: 404

// Usage with a string
const stringBox = new Box<string>("Refactor Complete");
console.log(stringBox.getValue()); // Output: Refactor Complete
Watch Out: Static members of a class cannot use the class's type parameter. Because static members belong to the class itself and not an instance, TypeScript cannot determine which "T" they should use.

2. Generic Class with Multiple Type Parameters

Sometimes a single type isn't enough. For example, if you are building a custom data store or a key-value pair system, you might need two independent types.

class HttpResponse<Data, Status> {
  payload: Data;
  status: Status;

  constructor(payload: Data, status: Status) {
    this.payload = payload;
    this.status = status;
  }
}

// We can pass an object for Data and a number for Status
const response = new HttpResponse({ username: "Dev123" }, 200);

 

Using Constraints in Generic Classes

Sometimes, you want a class to be generic, but only for types that meet certain criteria. For instance, you might want to ensure that the type passed into your class has a length property. You can achieve this using the extends keyword.

interface HasLength {
  length: number;
}

class ResourceInspector<T extends HasLength> {
  resource: T;

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

  logLength(): void {
    // This is only safe because of the constraint 'extends HasLength'
    console.log(`Length is: ${this.resource.length}`);
  }
}

const text = new ResourceInspector("Hello World"); // Strings have .length
const list = new ResourceInspector([10, 20, 30]); // Arrays have .length
// const num = new ResourceInspector(100); // Error: numbers don't have .length
Developer Tip: Constraints are excellent for writing "Generic but safe" utilities, such as database helpers that require an object to have an id property.

 

Generic Classes with Default Types

Just like default parameters in functions, you can provide a "fallback" type for your generic class. This makes the type argument optional when the class is instantiated.

class NotificationManager<T = string> {
  message: T;

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

// Defaults to string
const basic = new NotificationManager("System Update"); 

// Can still be overridden
const detailed = new NotificationManager({ code: 500, error: "Critical" });

 

Generic Classes with Methods

Generics are most useful when building data structures. Let's look at a practical Stack implementation. A stack is a "Last-In, First-Out" (LIFO) structure. By making it generic, we can have a stack of numbers, a stack of strings, or even a stack of UI components.

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];
  }
}

const historyStack = new Stack<string>();
historyStack.push("/home");
historyStack.push("/settings");
console.log(historyStack.pop()); // Output: "/settings"
Best Practice: Use generic classes for utility logic like Data Fetchers, State Managers, or Collection Wrappers. This allows you to reuse the logic across your entire application while maintaining specific types for each use case.

 

Summary

TypeScript Generic Classes are an essential part of a developer's toolkit for writing clean, DRY (Don't Repeat Yourself) code. By mastering them, you gain several advantages:

  • Code Reusability: Write the logic once and apply it to any data type.
  • Strong Type Safety: Avoid any and let TypeScript catch errors at compile time.
  • Predictable API: Constraints allow you to define exactly what your types are capable of (e.g., ensuring they have specific properties).
  • Cleaner Syntax: Default types help reduce boilerplate when the most common use case is known.

When you find yourself writing two classes that do the exact same thing but handle different data types, it's a clear sign that you should be using a Generic Class instead.