Generic constraints
Generics give us the power to write flexible, reusable code that works across many types—but that flexibility can quickly become a liability if we're not careful. Generic constraints let you set boundaries on what a type parameter can be, ensuring your generic functions and classes only accept types that make sense for what you're actually doing. Without them, you'll find yourself writing code that compiles but fails at runtime, or worse, gives users confusing error messages.
Over the past 40 days, you've learned how to read types, compose them, and make them flexible. Today we're going to harness that knowledge to write generics that are both powerful and safe. By the end of this article, you'll understand how to use extends, keyof, and property access patterns to catch errors before they happen.
Understanding the extends keyword for type parameters
The extends keyword is how you constrain a generic type parameter. It works exactly like it does for classes and interfaces—it defines a boundary. When you write <T extends SomeType>, you're telling TypeScript: "T can be SomeType or anything that's compatible with SomeType."
Let's start with a simple example. Imagine you're building a function that needs to work only with objects that have a length property. Without a constraint, you might write:
function getLength<T>(item: T): number {
return item.length; // Error: Property 'length' does not exist on type 'T'
}
getLength("hello"); // OK at runtime
getLength(42); // Compiles but breaks at runtime
TypeScript rejects this because T could be anything—even a number. The constraint fixes it:
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // ✓ string has length
getLength([1, 2, 3]); // ✓ array has length
getLength(42); // ✗ Error: Argument of type 'number' is not assignable to parameter of type '{ length: number }'
Now the error happens where it should—when someone tries to use the function incorrectly. I use this pattern constantly when working with collections or strings. It's the difference between "your code might break later" and "your code won't compile if it's wrong."
You can also constrain to primitive types or union types:
function process<T extends string | number>(value: T): T {
// We know value is either a string or number
return value;
}
process("test"); // ✓
process(123); // ✓
process(true); // ✗ Error
Accessing properties safely with extends
One of the most common places I use generic constraints is when a function needs to access a specific property on an object. Without a constraint, you can't safely access properties because TypeScript doesn't know what properties exist.
Here's a real scenario: you're building a CRM system and need a function that filters users by a specific property value. You might start with something broken:
function findByProperty<T, K>(items: T[], property: K, value: any): T[] {
return items.filter(item => item[property] === value); // Error: Type 'K' cannot be used to index type 'T'
}
The compiler rightly complains. We need to tell TypeScript that K is actually a property of T. We do this with extends:
function findByProperty<T, K extends keyof T>(items: T[], property: K, value: any): T[] {
return items.filter(item => item[property] === value);
}
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
const result = findByProperty(users, "name", "Alice"); // ✓ Works
const broken = findByProperty(users, "age", 30); // ✗ Error: Argument of type '"age"' is not assignable to parameter of type 'keyof User'
This catches the error immediately. In a real e-commerce platform, this kind of safety is essential. You don't want to ship code that tries to access properties that don't exist.
Using keyof constraints for property access
The keyof operator extracts all keys from a type. Combined with extends, it becomes incredibly powerful for building type-safe property accessors and transformers.
Here's the motivating example from the outline—the pluck function:
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = {
name: "Charlie",
age: 28,
email: "charlie@example.com",
};
const name = pluck(person, "name"); // ✓ name has type string
const age = pluck(person, "age"); // ✓ age has type number
const phone = pluck(person, "phone"); // ✗ Error: Argument of type '"phone"' is not assignable to parameter of type 'keyof typeof person'
Notice that the return type is T[K]—the actual type of that property on that object. So pluck(person, "name") returns a string, while pluck(person, "age") returns a number. TypeScript knows this without you having to spell it out.
I use this pattern all the time when building data transformation layers. For instance, in a ride-hailing platform, you might need to extract specific fields from a booking object to send to a payment service:
interface Booking {
id: string;
passengerId: string;
driverId: string;
amount: number;
status: "pending" | "completed" | "cancelled";
createdAt: Date;
}
function extractBookingData<K extends keyof Booking>(
booking: Booking,
...keys: K[]
): Pick<Booking, K> {
const result = {} as Pick<Booking, K>;
keys.forEach(key => {
result[key] = booking[key];
});
return result;
}
const paymentData = extractBookingData(booking, "id", "amount"); // ✓ { id, amount }
const invalidData = extractBookingData(booking, "id", "invalid"); // ✗ Error
The compiler ensures you only extract properties that actually exist on the Booking type. No runtime surprises, no data sent to the wrong fields.
Combining multiple constraints
Sometimes a single constraint isn't enough. You might need to ensure a type extends multiple things. You do this with an intersection:
interface HasId {
id: number;
}
interface HasName {
name: string;
}
function processEntity<T extends HasId & HasName>(entity: T): string {
return `Entity ${entity.id}: ${entity.name}`;
}
processEntity({ id: 1, name: "Widget" }); // ✓
processEntity({ id: 1 }); // ✗ Error: Property 'name' is missing
I've used this extensively when building trading platforms where orders must have certain metadata fields alongside domain-specific data. The constraint ensures consistency across different order types.
Clear error messages for wrong usage
When you constrain your generics, TypeScript gives you much better error messages. Compare these two scenarios:
Without constraints (confusing):
function getValue<T>(obj: T, key: string): any {
return obj[key]; // No error, but wrong
}
getValue(42, "length"); // Silently fails at runtime
With constraints (crystal clear):
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
getValue(42, "length"); // Error: Argument of type '42' is not assignable to parameter of type 'string | number'
The second error message tells you exactly what went wrong and why. It's the difference between debugging for hours and fixing it in seconds. When you're shipping production code—whether it's an e-commerce checkout or a CRM's data sync—these clear errors are invaluable.
Common mistakes
- Forgetting to apply the constraint everywhere: If you have a function that uses both
Tand needs to access properties, constrain both. I've seen people constrain the property key but not verify the object can be indexed. - Over-constraining too early: If your function genuinely works with any type, don't add a constraint just to feel safe. Constraints should reflect what your code actually needs.
- Mixing any with constraints: Writing
<T extends SomeType>(value: any)defeats the purpose. If you're accepting the parameter, type it properly or constrain it. - Not understanding keyof with unions:
keyof (A | B)gives you only keys that exist on both A and B. If you need all keys, usekeyof A | keyof Binstead.
Key takeaways
- Use
extendsto constrain what types a generic can represent—this prevents bugs and gives clearer error messages. - Combine
extends keyof Tto safely access properties on generic objects; the return type automatically becomes the correct property type. - When a function must interact with object properties, always constrain the key parameter. It catches mistakes at compile time, not production.
- Multiple constraints can be combined with intersection types when your function requires several capabilities.
- Constraints make your generics more restrictive but massively more reliable and easier to maintain.
Generic constraints are how you move from "this code compiles" to "this code is actually correct." Over 5+ years building platforms in production, I've learned that a well-constrained generic saves countless hours of debugging and refactoring. Tomorrow we'll explore how to use these constraints with utility types to transform types in even more powerful ways.






Comments
Loading comments…