Constraints with extends
Intermediate · 19 min read · ▶ live playground · ✦ checkpoint
Constraints with extends let you keep the generic link from 7.1 while requiring the tiny shape your function actually uses. <T extends { id: string }> means: for some type T, solve for T at the call site, but only allow a T that has a string id.
Why unconstrained T knows nothing
In 7.1, T was free. That was the point: first<T>(arr: T[]): T should work for strings, numbers, objects, and everything else. But a free T also means the function body is not allowed to assume much about it.
function readId<T>(item: T): string {
return item.id;
}playground.ts:2:15 - error TS2339: Property 'id' does not exist on type 'T'.
The checker is being literal. If T can be a string, a number, a boolean, or a task object, then item.id is not safe. The function has not promised that id exists.
A constraint puts a bound on the type parameter:
function readId<T extends { id: string }>(item: T): string {
return item.id;
}Read it out loud: "readId, for some type T that has a string id, takes a T and returns a string." The body can use item.id because every allowed T includes that property.
Bound the type, keep the caller's shape
The important part is what the constraint does not do. It does not replace T with exactly { id: string }. It only says T must fit inside that boundary. The checker still solves for T from the argument and keeps the extra fields.
remember can read item.id inside the function. At the same time, task still has title and done, and invoice still has total and paid. That is the useful generic bargain: the constraint gives the implementation one safe fact, while T still does the job from 7.1 — link input type to output type.
Think of <T extends { id: string }> as a minimum promise, not a final type. The function says, "I need at least this much to do my work; you can bring a richer object and I will preserve it." That is different from annotating the parameter as exactly { id: string }, which would throw away the caller's more precise shape at the return boundary.
Keys that are actually keys
Constraints become more interesting when one type parameter depends on another. A common example is safe property access: you want a function that reads a property, but the key must be one of the keys of this object type.
That is what keyof gives you here. Locally, read keyof T as "the keys of T." Deeper keyof and typeof patterns come later; for now, use it to connect the key argument to the object argument.
Read the signature slowly:
T extends object: for some object type T.K extends keyof T: for some key K, but only a key that exists on T.T[K]: the property type at that key.
So readProperty(lesson, "title") returns string, while readProperty(lesson, "minutes") returns number. One generic function links input type to output type more precisely than a loose string key ever could.
The over-constraint trap
The trap is writing the constraint from the first example you happen to have, instead of from the work the function actually does.
type Ticket = {
id: string;
title: string;
status: "open" | "closed";
};
type Customer = {
id: string;
email: string;
};
function idOfTicket<T extends Ticket>(item: T): string {
return item.id;
}
function idString(item: { id: string }): string {
return item.id;
}
function rememberWithId<T extends { id: string }>(item: T): T {
console.log("remembered:", item.id);
return item;
}
const customer: Customer = { id: "customer-9", email: "team@example.com" };
console.log(idString(customer));
const sameCustomer = rememberWithId(customer);
console.log(sameCustomer.email.toUpperCase());→ customer-9 | remembered: customer-9 | TEAM@EXAMPLE.COM
idOfTicket looks generic, but its constraint says every caller must be a Ticket even though the body only reads id. If the function only returns the id string, the plain idString(item: { id: string }) signature is clearer. If the function should stay generic, make it preserve the solved T, like rememberWithId does.
Here is the over-constraint doing real damage:
type Ticket = {
id: string;
title: string;
status: "open" | "closed";
};
type Customer = {
id: string;
email: string;
};
function idOfTicket<T extends Ticket>(item: T): string {
return item.id;
}
const customer: Customer = { id: "customer-9", email: "team@example.com" };
idOfTicket(customer);→ playground.ts:17:12 - error TS2345: Argument of type 'Customer' is not assignable to parameter of type 'Ticket'. Type 'Customer' is missing the following properties from type 'Ticket': title, status
Next lesson moves the same idea from functions into reusable type shapes: generic aliases, interfaces, and classes. The question stays the same: which type should be chosen by the caller, and where should that choice be preserved?
Checkpoint
Answer all three to mark this lesson complete