Skip to Content
Course content

233: Using Type Parameters in Generic Constraints

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 dive into advanced generics: they treat constraints as a simple "checklist" for properties. They think that if they want a type to be "comparable," they just need to make sure it implements a Comparable interface. This seems intuitive, but it leads to a subtle, frustrating bug where you lose type safety exactly where you need it most.

The "Any Comparable" Trap

Imagine you're building a sorting utility. You define a Comparable interface, and you want your sort function to work with any type that can compare itself to another object of the same type. Here is how most people first try to write it:

interface Comparable {
  compareTo(other: Comparable): number;
}

function getMax<T extends Comparable>(a: T, b: T): T {
  return a.compareTo(b) > 0 ? a : b;
}

class User implements Comparable {
  constructor(public name: string, public age: number) {}
  compareTo(other: Comparable): number {
    return this.age - (other as User).age;
  }
}

class Product implements Comparable {
  constructor(public title: string, public price: number) {}
  compareTo(other: Comparable): number {
    return this.price - (other as Product).price;
  }
}

const user = new User("Alice", 30);
const product = new Product("Laptop", 1200);

// Wait... TypeScript lets me do this?!
getMax(user, product); 

Look closely at that last line. getMax(user, product) shouldn't be allowed. You can't compare a User to a Product. But because T is constrained to Comparable, and both classes implement that interface, TypeScript sees them as compatible. Even worse, look at the compareTo method—I had to use as User (a type cast) because the interface only knows that other is some Comparable, not necessarily another User. That's a huge red flag in a TypeScript codebase.

Binding the Type to Itself

To fix this, we use a pattern called F-bounded polymorphism. Instead of constraining T to a static interface, we constrain T to an interface that uses T as its own type parameter. I like to think of this as "locking" the type to itself.

Here is the correct way to handle this:

interface Comparable<T> {
  compareTo(other: T): number;
}

// Now T must extend Comparable of ITSELF
function getMax<T extends Comparable<T>>(a: T, b: T): T {
  return a.compareTo(b) > 0 ? a : b;
}

class User implements Comparable<User> {
  constructor(public name: string, public age: number) {}
  
  // No more type casting! 'other' is guaranteed to be a User.
  compareTo(other: User): number {
    return this.age - other.age;
  }
}

class Product implements Comparable<Product> {
  constructor(public title: string, public price: number) {}
  
  compareTo(other: Product): number {
    return this.price - other.price;
  }
}

const user = new User("Alice", 30);
const product = new Product("Laptop", 1200);

// This now throws a compile-time error:
// Argument of type 'Product' is not assignable to parameter of type 'User'.
getMax(user, product); 

By changing the constraint to T extends Comparable<T>, you're telling TypeScript: "T must be a type that knows how to compare itself to other instances of T." This creates a recursive relationship that prevents User and Product from being mixed, even though they both technically follow the same structural pattern. It's a bit of a brain-bender the first time you see it, but it's the only way to ensure true type safety in polymorphic hierarchies.




📋 Practical Task

Implementing a Type-Safe Fluent Entity Builder

You are building a library for creating complex API entities. You want to implement a "Fluent Builder" pattern where each builder method returns this, but you want the builder to remain generic so that subclasses (like UserBuilder and AccountBuilder) return their own specific type rather than the base EntityBuilder type.

Your Task: Fix the EntityBuilder base class using a type parameter in its generic constraint so that the setName method returns the specific subclass type, allowing for chained calls without losing the subclass's unique methods.

interface BaseEntity {
  name: string;
}

// TODO: Update the class definition to use F-bounded polymorphism
class EntityBuilder {
  entity: BaseEntity = { name: "" };

  setName(name: string) {
    this.entity.name = name;
    return this; // Currently returns EntityBuilder, not the subclass!
  }
}

class UserBuilder extends EntityBuilder {
  setUserRole(role: string) {
    console.log(`Setting role to ${role}`);
    return this;
  }
}

// This should work without type casting:
const builder = new UserBuilder();
builder.setName("Bob").setUserRole("Admin"); 
// Currently, .setName() returns EntityBuilder, 
// so .setUserRole() causes a TS error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.