Chapter 5.4☕ 17 min read

Constraining Generics with extends

Any type can enter — but only if it follows the rules.

01The VIP Dress Code

Unconstrained generics are like an open house party — anyone and anything can walk in. When you write <T> with no restrictions, TypeScript says "sure, T can be anything." String? Sure. Number? Why not. Boolean? Come on in. Null? Undefined? Record<string, any[]>? All welcome, no questions asked. The problem is that inside your function, you often need some structure from T. If you are writing a function that calls .length on T, and someone passes in a number, your code breaks at runtime. TypeScript cannot help you because you told it T is anything.

Constraints solve this problem elegantly. They say: "T can be ANY type, BUT it must AT LEAST have these properties." Think of it like a VIP Club at a posh Banjara Hills lounge. The bouncer does not care WHO you are — student, IT guy, business owner, Nizam's great-grandson — anyone can enter. BUT you must follow the DRESS CODE: you must have shoes and a collared shirt. That is the constraint — <T extends { shoes: boolean; collar: boolean }>. If you show up in slippers and a banyan (no shoes, no collar), you are OUT.

The bouncer — the TypeScript compiler — checks: does this type have the required properties? If yes, welcome in! If no, compilation error. Constraints make generics both flexible and safe — any type that meets the minimum requirements is welcome, and TypeScript guarantees those requirements are met inside the function body. Without constraints, generics are too wild. With constraints, they are powerful and type-safe. This is the balance that makes TypeScript generics truly useful in production code.

02The extends Constraint

The extends keyword is how you apply a constraint to a generic type parameter. The syntax is simple: <T extends ConstraintType>. This tells TypeScript: "T can be any type, as long as it is assignable to ConstraintType." Let us start with the classic example — constraining T to types that have a .length property:

function logLength<T extends { length: number }>(
  item: T
): number {
  return item.length;
}

Now TypeScript knows that whatever T is, it definitely has a .length property of type number. So item.length is safe to access. Let us see which types pass the constraint check:

logLength("hello");       // ✓ works, T=string
logLength([1, 2, 3]);     // ✓ works, T=number[]
logLength({ length: 7 }); // ✓ works, T={length}
logLength(42);            // ✗ ERROR! number
logLength(true);          // ✗ ERROR! boolean

Strings have .length, arrays have .length, custom objects with a length property work too. But number and boolean do not have .length, so they fail the constraint. TypeScript catches this at compile time — no runtime surprises!

You can also constrain to an interface. This is extremely common when working with database entities or API responses:

interface HasId {
  id: string;
}

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

Now T can be User, Product, Order — any type that has an id: string property. But passing a number[] would fail because numbers do not have .id. The return type is T | undefined, which means you get back the specific type T, not just HasId. This preserves type information beautifully!

You can also use keyof in constraints. Here is a preview of Chapter 5.6 — the getProperty function that safely reads any property from an object:

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

const user = { name: "Ravi", age: 28 };
getProperty(user, "name");  // string
getProperty(user, "age");   // number
getProperty(user, "email"); // ERROR!

The constraint K extends keyof T ensures that the key you pass actually exists on the object. If you try to access a property that does not exist, TypeScript catches it immediately. This is one of the most powerful constraint patterns in TypeScript — it connects two type parameters together, so the second one must be a valid key of the first.

03Common Constraint Patterns

There are several constraint patterns that come up again and again in real TypeScript code. Knowing these patterns will help you read and write constrained generics fluently. Let us go through the most common ones, with examples for each.

Pattern 1: Union Constraint<T extends string | number>. T must be either a string or a number. This is useful for ID types that can be either format:

type IdType = string | number;

function findItem<T extends IdType>(
  id: T
): void {
  console.log(
    typeof id === "string"
      ? id.toUpperCase()
      : id.toFixed(2)
  );
}

Pattern 2: Object Constraint<T extends object>. T must be a non-primitive. This excludes null, undefined, string, number, and boolean. Only objects, arrays, and functions pass:

function merge<T extends object, U extends object>(
  a: T,
  b: U
): T & U {
  return { ...a, ...b };
}

merge({ x: 1 }, { y: 2 }); // OK
merge("hello", { y: 2 });  // ERROR!

Pattern 3: Length Constraint<T extends { length: number }>. T must have a length property. Strings, arrays, and custom objects with length all qualify. This is perfect for functions that work with "sequence-like" data:

function getLength<T extends { length: number }>(
  item: T
): number {
  return item.length;
}

getLength("hello");       // 5
getLength([1, 2, 3]);     // 3
getLength({ length: 99 }); // 99

Pattern 4: HasId Constraint<T extends HasId>. The most common pattern for database entities. Any type with an id property qualifies. This pattern appears in almost every repository or data-access layer:

interface HasId {
  id: string;
}

interface User extends HasId {
  name: string;
}

interface Product extends HasId {
  title: string;
  price: number;
}

function removeById<T extends HasId>(
  items: T[],
  id: string
): T[] {
  return items.filter(
    item => item.id !== id
  );
}

Pattern 5: Keyof Constraint<K extends keyof SomeType>. K must be a key of a specific type. This ensures type-safe property access and is the backbone of many utility types:

function pluck<T, K extends keyof T>(
  items: T[],
  key: K
): T[K][] {
  return items.map(
    item => item[key]
  );
}

const users = [
  { name: "A", age: 25 },
  { name: "B", age: 30 }
];

pluck(users, "name"); // string[]
pluck(users, "age");  // number[]

Pattern 6: Constructor Constraintnew () => T. T must be a class that can be instantiated with no arguments. This is used in factory patterns and dependency injection:

function create<T>(
  Cls: new () => T
): T {
  return new Cls();
}

class Person {
  greet() {
    return "Hello!";
  }
}

const p = create(Person);
// p is typed as Person
console.log(p.greet()); // "Hello!"

Each pattern serves a different purpose. The union constraint limits to specific types. The object constraint excludes primitives. The length constraint requires a countable sequence. The HasId constraint is for identifiable entities. The keyof constraint enables type-safe property access. And the constructor constraint enables factory creation. Master these six and you will handle 95% of real-world constraint scenarios!

04Constraint Traps

Constraining generics is powerful, but there are several traps that catch developers off guard. Let us walk through the most common mistakes so you can avoid them in your own code.

Trap 1: Constraint Too Loose. Using <T extends object> is very broad. The only thing it guarantees is that T is not a primitive. It does not give you access to any specific properties or methods. If you need .length, use <T extends { length: number }> instead. Always use the most specific constraint that captures your requirement:

// Too loose!
function process<T extends object>(data: T) {
  console.log(data.length); // ERROR!
  // object has no .length
}

// Better — specific constraint
function process<T extends { length: number }>(
  data: T
) {
  console.log(data.length); // OK!
}

Trap 2: Constraint vs Type Annotation Confusion. <T extends string> and (item: string) are NOT the same thing. The constraint means T can be string or any subtype of string (like the literal type "hello"). The annotation forces the parameter to be exactly string. With constraints, the caller can pass literal types and the compiler preserves that information. With plain annotations, the type gets widened:

// Constraint: preserves literal types
function logA<T extends string>(val: T): T {
  return val;
}
const a = logA("hello");
// type of a is "hello", not string!

// Annotation: widens to string
function logB(val: string): string {
  return val;
}
const b = logB("hello");
// type of b is just string

Trap 3: Constraint Does Not Give All Methods. If you constrain <T extends { length: number }>, you only get .length. You do NOT get .toUpperCase() or .push() or .slice(). You only get what the constraint explicitly declares. This trips up many developers who expect all of string's or array's methods:

function process<T extends { length: number }>(
  item: T
) {
  console.log(item.length);  // OK
  console.log(item.toUpperCase()); // ERROR!
  console.log(item.push(1)); // ERROR!
  // Only .length is available
}

Trap 4: Over-Constraining. Do not add constraints you do not need. If your function does not use .length, do not add extends { length: number }. Unnecessary constraints make your function less reusable. Other developers will be forced to satisfy a constraint that serves no purpose:

// BAD: constraint you never use
function printItem<T extends { length: number }>(
  item: T
) {
  console.log(item); // .length unused!
}

// GOOD: only constrain what you need
function printItem<T>(item: T) {
  console.log(item);
}

Trap 5: Constraint with any. Writing <T extends any> is the same as unconstrained <T>. It is meaningless but TypeScript does not throw an error. It just adds noise to your code. Do not write it:

// Meaningless — same as <T>
function process<T extends any>(item: T) {
  // T is still unconstrained
}

// Just write this instead
function process<T>(item: T) {
  // Same thing, cleaner
}

Trap 6: Self-Referencing Constraint. <T extends T> is always true and therefore pointless. It looks weird, TypeScript allows it, and it does absolutely nothing. Do not write this — it confuses other developers and adds no value. If you see it in code, it is a bug or a misunderstanding.

Keep these traps in mind and your constrained generics will be clean, useful, and bug-free. The key principle: constrain exactly what you need, nothing more and nothing less!

05Constraining Generics Cheatsheet

Let us recap everything we have learned about constraining generics with extends. This is your quick-reference cheatsheet — bookmark it, come back to it, and use it whenever you need a refresher.

Syntax:

// Basic constraint
function fn<T extends ConstraintType>(
  arg: T
) {}

// Multiple constraints via intersection
function fn<T extends A & B>(arg: T) {}

// Constraint on interface
interface Repo<T extends HasId> {
  findById(id: string): T;
  findAll(): T[];
}

// Constraint on class
class Store<T extends HasId> {
  items: T[] = [];
  add(item: T) {
    this.items.push(item);
  }
}

Common Constraints Quick Reference:

  • <T extends { length: number }> — T must have a .length property. Use for strings, arrays, and custom sequence-like objects.
  • <T extends object> — T must be a non-primitive. Use when you need to spread, merge, or iterate over keys.
  • <T extends string | number> — T must be a string or number. Use for ID types and union-based constraints.
  • <T extends HasId> — T must have an id property. Use for database entities, API models, and data-access layers.
  • <K extends keyof T> — K must be a key of T. Use for type-safe property access and utility functions.
  • new () => T — T must be a class with a no-arg constructor. Use for factory patterns and dependency injection.

The Five Rules of Constraining Generics:

  • Rule 1: A constraint is a minimum requirement, not an exact type. <T extends string> allows string AND its subtypes (like "hello").
  • Rule 2: Only properties and methods declared in the constraint are accessible inside the function. <T extends { length: number }> gives you .length but not .toUpperCase().
  • Rule 3: Use the most specific constraint needed. <T extends { length: number }> is better than <T extends object> when you need .length.
  • Rule 4: Do not over-constrain. If your function does not use a property, do not require it. Unnecessary constraints reduce reusability.
  • Rule 5: Constraints work on interfaces and classes too: interface Repo<T extends HasId>, class Store<T extends HasId>.

The Golden Rule: Constraints are the VIP dress code — anyone can enter, but you must have shoes and a collar. Check the minimum, do not over-check, and the bouncer (TypeScript) will let the right types in. Think of it this way: you want Hussain Sagar to be open to everyone — boating, walking, eating — but you still need a ticket to get on the boat. The ticket is the constraint. It does not care who you are, just that you have paid. Constraints make generics work the same way — open to all types that meet the minimum bar, closed to those that do not!

Key Points — Constraining Generics

  • Use to set minimum requirements on T
  • Constraints are minimum requirements, not exact types
  • Only properties declared in the constraint are accessible inside the function
  • Use the most specific constraint needed — do not over-constrain
  • Common patterns: { length: number }, object, HasId, keyof T, new()
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase