Right, so you’ve got generic functions under your belt. They’re fantastic for writing one piece of logic that works across different types. But what about when you want to bake that same flexibility into a blueprint? That’s where generic classes come in. Think of them as a cookie cutter where you get to decide what kind of dough you’re using at the very last second.

A generic class is simply a class that has a type parameter (or several). You define this parameter list in angle brackets < > right after the class name. This parameter is a placeholder for the actual type that a user of your class will provide.

// Let's say we're building a box that can hold anything.
// That <T> is our type parameter. You can call it whatever you want,
// but T (for Type), K (for Key), V (for Value) are common conventions.
class FancyBox<T> {
    private contents: T;

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

    getContent(): T {
        return this.contents;
    }

    setContent(newContent: T): void {
        this.contents = newContent;
    }
}

Now, watch the magic. When we create an instance, we apply the type by telling it what T should be. The class is no longer a vague idea; it becomes a specific, strongly-typed class for that particular type.

// Now it's a box specifically for strings. Try putting a number in it. I dare you.
const stringBox = new FancyBox<string>("My precious");
console.log(stringBox.getContent().toUpperCase()); // "MY PRECIOUS"

const numberBox = new FancyBox<number>(42);
// numberBox.setContent("nope"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.

Why Not Just Use any?

I can hear you thinking, “Couldn’t I just make contents: any and call it a day?” You could. And then I would have to come over there and have a very serious talk with you about the point of using TypeScript.

Using any nukes the entire type system for that property. You lose all safety. You could put a string in the box and then try to call toFixed() on it, and you’d only find out when your program crashed. With a generic, the type is preserved. The box knows it’s holding a number, so it knows toFixed() is a safe method to call. It’s the difference between a labeled container and a mysterious, potentially explosive unmarked box.

Constraining Your Generics with extends

Sometimes, you don’t want a box that can hold literally anything. That’s chaos. Maybe you want a box that only holds things that have a name: string property. This is where constraints come in. We use the extends keyword to tell our type parameter, “You can be any type you want, as long as you have at least this shape.”

interface Named {
    name: string;
}

// T can be a string, a Person, a Dog—anything that has a 'name' property.
class NamedBox<T extends Named> {
    private item: T;

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

    getLabel(): string {
        // Because we've constrained T, the compiler knows `item` has a `name`.
        return `This box contains: ${this.item.name}`;
    }
}

const personBox = new NamedBox({ name: "Alice", age: 30 }); // Works
console.log(personBox.getLabel()); // "This box contains: Alice"

// const numberBox = new NamedBox(42); // Error: Property 'name' is missing on type '42'

Default Types: For When You’re Feeling Indecisive

What if you want a sensible default for your generic type? TypeScript lets you provide one right in the declaration. This is great for backward compatibility or for when your class is usable 80% of the time with a specific type.

// If someone doesn't specify a type, we'll assume they want a string.
class DefaultBox<T = string> {
    contents: T;

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

// Uses the default type of string.
const box1 = new DefaultBox("hello");
// Explicitly sets the type to number.
const box2 = new DefaultBox<number>(42);

The Quirks and “Oh, C’mon” Moments

Here’s the part the manual often glosses over. Generics are a compile-time construct. They exist to make your life as a developer easier and don’t show up in the resulting JavaScript code. This is called type erasure. The emitted JS for our FancyBox class would just have this.contents with no type information whatsoever.

Also, and this is a big one, you can’t create new instances of a generic type T inside the class. The compiler has no idea if T has a constructor.

class Creator<T> {
    createInstance(): T {
        return new T(); // Error: 'T' only refers to a type, but is being used as a value here.
    }
}

To do this, you’d need to pass in a constructor function, which is a pattern, but it’s a bit clunky. It’s a trade-off. The type system is powerful, but it’s not magic—it can’t know how to construct every possible type at runtime.

The final pro-tip: don’t go overboard with type parameters. A class with <T, K, V, U, W> is a nightmare to read and use. If you find yourself needing more than two or three, it might be a sign that your class is doing too much and should be refactored. Keep it smart, keep it typed, and for heaven’s sake, stop trying to use any.