-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
233: Using Type Parameters in Generic Constraints
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.
There are no comments for now.