RizTech Academy logo
RizTech Academy
GenericsLesson 3 of 530 min

Constraining a generic with extends

A bare <T> means "any type at all" — which is often too permissive. If your generic function needs to use a property of T (its length, its id), a bare T will not let you, because T might be a type that lacks it. Constraints — T extends SomeType — restrict a type parameter to types that have what you need, and they are what make generics both flexible and safe.

The problem: you cannot use what T might not have

function longest<T>(a: T, b: T): T {
  return a.length > b.length ? a : b;   // error: Property 'length' does not exist on type 'T'
}

This fails because T could be anything — a number, a boolean — and those have no .length. TypeScript is right to stop you: it cannot guarantee T has a length. A bare type parameter only lets you do things valid for every possible type, which is almost nothing (assign it, pass it, put it in an array). To use .length, you must promise that T has a length — and that promise is a constraint.

The solution: extends

T extends Constraint restricts T to types that are assignable to the constraint — types that have at least what the constraint requires:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;    // fine now — T is guaranteed to have a length
}

longest("Pune", "Mumbai");        // fine — strings have length
longest([1, 2], [1, 2, 3]);       // fine — arrays have length
longest(10, 20);                  // error: number does not have a length property

T extends { length: number } says "T can be any type, as long as it has a length: number property". Now inside the function you may use a.length, because every T is guaranteed to have one. And the constraint is checked at the call site: longest(10, 20) fails because a number does not satisfy the constraint. A constraint is a promise about T that unlocks the corresponding capabilities inside the function and is enforced on callers.

Crucially, the return type is still the specific T you passed — longest("Pune", "Mumbai") returns a string, not { length: number }. You constrain T to require a length, but callers still get back their exact type. That is the win over just typing the parameters as { length: number }: you keep the precise type while requiring the capability.

The keyof constraint — a type-safe property getter

The most useful constraint in everyday code relates two type parameters, using keyof (which you will meet fully in the advanced-types module). It types a function that safely gets a property by name:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const member = { name: "Kavita", age: 33, city: "Pune" };

getProperty(member, "name");    // returns string  (T[K] where K is "name")
getProperty(member, "age");     // returns number
getProperty(member, "email");   // error: Argument '"email"' is not assignable to '"name" | "age" | "city"'

Read the two type parameters: T is the object's type, and K extends keyof T means "K is one of T's keys". keyof T for the member above is the union "name" | "age" | "city", so K is constrained to those keys — passing "email" is a compile error because it is not a key of member. And the return type T[K] (an indexed access type) is "the type of the property at key K" — so getProperty(member, "age") returns exactly number. This is a genuinely useful, fully type-safe property accessor, and it is a beautiful demonstration of constraints: two related type parameters, one constraining the other, with a precise return type. You will see this pattern in real libraries.

Constraining to a shape with an id, and to a class

Constraints are how you write generics that work with "any type that has these properties":

interface HasId {
  id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);   // fine — every T has an id
}

const users = [{ id: "1", name: "Kavita" }, { id: "2", name: "Ravi" }];
const found = findById(users, "1");    // returns { id: string; name: string } | undefined — the FULL type

T extends HasId requires every T to have an id: string, so item.id is safe inside — while findById(users, "1") still returns the full user type (with name), not just HasId. This "works with anything that has an id" pattern is everywhere in real code — repositories, caches, data stores — and it is the everyday face of constraints.

Constraints with defaults, and multiple constraints

A type parameter can be both constrained and have a default:

function makeStore<T extends { id: string } = { id: string }>() { /* ... */ }

And you constrain to multiple requirements with an intersection:

function process<T extends { id: string } & { name: string }>(item: T) {
  console.log(item.id, item.name);    // both guaranteed
}

T extends { id: string } & { name: string } requires T to have both an id and a name. Use an intersection in the constraint when a T must satisfy several shapes at once.

The mental model

A constraint answers the question "what does this generic need to know about T?" A bare <T> knows nothing about T, so it can do almost nothing with it — only pass it around. The moment you want to use T — access a property, call a method, index into it — you must constrain T to guarantee that capability. So: write the loosest constraint that lets your function do its job. No constraint if you only pass T around (like identity or first); extends { length: number } if you need a length; extends keyof T for a key; extends HasId for an id. The constraint documents the generic's requirements and keeps it as flexible as possible while staying safe.

Check your work

Why a bare <T> is often too permissive. T could be any type, so you can only do what is valid for every type — you cannot use T-specific properties like .length.

What T extends Constraint does. Restricts T to types assignable to the constraint (having at least what it requires), unlocking those capabilities inside and enforcing them on callers.

Whether the return type is widened by the constraint. No — callers still get their exact T back; you require a capability without losing precision.

The keyof constraint. K extends keyof T restricts K to T's keys, and T[K] (indexed access) returns the precise property type — a type-safe property getter.

The HasId pattern. T extends HasId lets a generic work with "any type that has an id" while returning the full type — common in repositories and stores.

Constraint plus default, and multiple constraints. A type parameter can be constrained and have a default; use an intersection (A & B) to require several shapes.

The mental model. Write the loosest constraint that lets the function do its job — none if you only pass T around, a specific one when you need to use T.

Practice

  1. Write longest<T>(a, b) using .length on a bare T and read the error. Add extends { length: number } and confirm it works for strings and arrays but not numbers.
  2. Confirm longest("Pune", "Mumbai") returns string, not { length: number }.
  3. Write getProperty<T, K extends keyof T>(obj, key): T[K]. Get a valid property (confirm the precise return type) and an invalid key (read the error).
  4. Write findById<T extends HasId> and confirm it returns the full item type.
  5. Add a default to a constrained type parameter and use it both ways.
  6. Constrain a T to two shapes with an intersection and use a property from each.
  7. For three generic functions, decide the loosest constraint each needs and justify it.

Official documentation

Next: generic interfaces and classes.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship