TypeScript generics with constraints can feel like advanced wizardry, but they’re actually a powerful tool for making your code more robust and reusable by ensuring that the types you pass into generic functions or classes meet specific requirements.

Let’s say you’re building a function that needs to work with objects that have a specific property, like an id. You could write this:

function getItemById(items: any[], id: number): any | undefined {
  return items.find(item => item.id === id);
}

This works, but it’s not type-safe. any tells TypeScript to stop checking types, which defeats the purpose of using it. If items is an array of strings, or if the objects don’t have an id property, you’ll get runtime errors.

Generics let us define a placeholder for a type, T:

function getItemById<T>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

Now, T represents the type of items in the items array. But TypeScript still doesn’t know if T has an id property. This will still throw an error: Property 'id' does not exist on type 'T'.

This is where constraints come in. We can tell TypeScript that T must extend a certain type, meaning it must have at least the properties of that type.

To ensure T has an id property, we can constrain it to an interface:

interface Identifiable {
  id: number;
}

function getItemById<T extends Identifiable>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

// This works:
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
const user = getItemById(users, 1); // user is correctly typed as { id: number, name: string } | undefined

// This would cause a TypeScript error:
// const products = [{ name: 'Laptop' }, { name: 'Mouse' }];
// const product = getItemById(products, 1); // Error: Argument of type '{ name: string; }[]' is not assignable to parameter of type 'Identifiable[]'.

Here, T extends Identifiable means that whatever type T ends up being, it must have an id property of type number. This gives us type safety. The compiler will enforce that you can only call getItemById with arrays of objects that satisfy the Identifiable interface.

Beyond simple property existence, constraints are incredibly useful for more complex scenarios. Consider mapping over an array and transforming each element. You want to ensure the input array and output array are related, but not necessarily identical.

Let’s say you have a function that takes an array of objects, extracts a specific property from each, and returns an array of those extracted values. A common pattern is to extract a property name as a string and then access it.

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

const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
const names = pluck(users, 'name'); // names is string[]
const ids = pluck(users, 'id');   // ids is number[]

// This would error:
// const emails = pluck(users, 'email'); // Error: Argument of type '"email"' is not assignable to parameter of type '"id" | "name"'.

In pluck<T, K extends keyof T>, T is the type of the objects in the array, and K extends keyof T means K must be one of the keys (property names) of T. The return type T[K][] is a mapped type, meaning it’s an array of the type of the property K on type T. This is powerful because it correctly infers the return type based on the property you choose.

You can also use intersection types in constraints. Imagine a function that needs to operate on objects that are both Identifiable (have an id) and Timestamped (have createdAt and updatedAt).

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

function processTimestampedItem<T extends Identifiable & Timestamped>(item: T): void {
  console.log(`Processing item ${item.id}, created at ${item.createdAt.toISOString()}`);
}

const event = {
  id: 101,
  name: 'Meeting',
  createdAt: new Date(),
  updatedAt: new Date(),
};

processTimestampedItem(event); // Works!

// This would error:
// const simpleItem = { id: 5 };
// processTimestampedItem(simpleItem); // Error: Argument of type '{ id: number; }' is not assignable to parameter of type 'Identifiable & Timestamped'.

Here, T extends Identifiable & Timestamped ensures that T must satisfy both interfaces. The & is an intersection type, meaning the type must have all properties from Identifiable and all properties from Timestamped.

When you define a generic function or class, you’re essentially creating a blueprint. Constraints are the blueprints’ specific requirements for the materials you can use. They prevent you from trying to build a house with sandcastles. The compiler acts as the strict building inspector, ensuring you only use materials that meet the specified standards before construction even begins. This upfront validation catches a huge class of errors that would otherwise manifest at runtime, often in unexpected places.

One of the more subtle but incredibly useful aspects of generic constraints is their interaction with conditional types and recursive generic definitions. For instance, you can create a type that recursively deep-merges objects, but only if the properties being merged are compatible, using constraints to check that compatibility at each level of recursion. This allows for highly precise type manipulation that mirrors complex object structures and their transformations without sacrificing type safety.

The next step in mastering generics involves understanding how to use them with abstract classes and how to create generic utility types that can transform existing types.

Want structured learning?

Take the full Typescript course →