Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've noticed a recurring pattern when developers first move from generic functions to generic classes. There's this nagging feeling that class Box<T> is basically just a fancy way of saying "this class uses any, but I'm promising TypeScript I'll be careful."

Generics are not just "type-safe any"

If you treat a generic class like a container for any, you're missing the entire point of the feature. The misconception is thinking that T remains fluid throughout the life of the object. It doesn't. Once you instantiate a generic class, T is locked in for that specific instance.

// The "I think this is just any" approach
class DataStore<T> {
  private storage: T[] = [];
  add(item: T) { this.storage.push(item); }
  getLatest(): T { return this.storage[this.storage.length - 1]; }
}

// Someone might think they can do this:
const myStore = new DataStore(<any>()); 
myStore.add("Hello");
myStore.add(42); // This works because they used 'any', but it's useless.

The power of generic classes isn't about allowing any type—it's about enforcing consistency across the entire instance. When you instantiate new DataStore<string>(), you aren't just telling TypeScript what's inside; you're creating a contract that the add method and the getLatest method must agree on the same type for the rest of that object's life.

Locking the type at instantiation

Let's look at a real-world scenario: a ResponseWrapper. In a professional API client, you don't want to manually cast your data every time you get a response. You want the class to "remember" what it's holding.

interface User { id: number; name: string; }
interface Product { id: number; price: number; }

class ResponseWrapper<T> {
  constructor(public data: T, public timestamp: Date) {}

  logData() {
    console.log(`Received data at ${this.timestamp}:`, this.data);
  }
}

// Now, these are two completely different types of objects
const userResponse = new ResponseWrapper<User>({ id: 1, name: "Alice" }, new Date());
const productResponse = new ResponseWrapper<Product>({ id: 101, price: 29.99 }, new Date());

// TypeScript knows exactly what's inside. 
// No casting needed, and no 'any' in sight.
console.log(userResponse.data.name); // Safe!
console.log(productResponse.data.price); // Safe!

I love this pattern because it removes the "guesswork" from your data layer. The class becomes a blueprint, and the instantiation becomes the specific implementation.

Adding guardrails with the extends keyword

Sometimes, leaving T completely open is too permissive. You might want a generic class, but you need to ensure that whatever T is, it has certain properties. This is where constraints come in. If you try to access a property on T without a constraint, TypeScript will scream at you because it can't guarantee that T will actually have that property.

interface Identifiable {
  id: string;
}

// We tell TS: "T can be anything, as long as it has at least an id string"
class Repository<T extends Identifiable> {
  private items: Map<string, T> = new Map();

  save(item: T) {
    this.items.set(item.id, item); // This only works because of 'extends Identifiable'
  }

  findById(id: string): T | undefined {
    return this.items.get(id);
  }
}

// This works because User has an id
interface User { id: string; username: string; }
const userRepo = new Repository<User>();

// This will fail! Product is missing the 'id' string.
interface Product { sku: string; price: number; }
// const productRepo = new Repository<Product>(); 

By using extends, you're essentially saying: "I want the flexibility of generics, but I need a minimum guarantee of what the data looks like." It's the best of both worlds.




📋 Practical Task

Build a Type-Safe PaginatedResult Container

You need to create a generic class called PaginatedResult<T> that handles a list of items and metadata about the pagination. This is a common requirement for backend-to-frontend data transfer.

Requirements:

  • The class should accept a generic type T.
  • It should have a constructor that takes:
    • items: An array of T.
    • totalPages: A number.
    • currentPage: A number.
  • Implement a method hasNextPage(): boolean that returns true if the currentPage is less than the totalPages.
  • Implement a method getFirstItem(): T | null that returns the first item in the list or null if the list is empty.

Test your implementation: Create a Customer interface with name and email. Instantiate your PaginatedResult using this interface and verify that getFirstItem() correctly returns a Customer object with full type-hinting (autocomplete for name and email).

Rating
0 0

There are no comments for now.

to be the first to leave a comment.