Advanced TypeScript: Generic Constraints
Welcome to the Generic Constraints lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
Suppose that we want a function that gives us only the users under a certain age. It takes a
User[]and anumberas the age limit, then returns aUser[].>
type User = {name: stringage: number};function filterBelowAge(users: User[], limit: number): User[] {return users.filter(user => user.age < limit);}const users = [{name: 'Amir', age: 36},{name: 'Gabriel', age: 7},];const youngUsers = filterBelowAge(users, 10);youngUsers.map(user => user.name);Result:
['Gabriel']
Now let's make it more difficult. We want our function to work on any object that has an
age: numberproperty. It should work for users, for cats, for buildings, for companies, and for any other object with an age. As a first attempt, we can try to use a union.>
type User = {name: string, age: number, country: string};type Cat = {name: string, age: number, vaccinated: boolean};function filterBelowAge(things: Array<User | Cat>,limit: number): Array<User | Cat> {return things.filter(thing => thing.age < limit);}- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const peopleAndCats = [{name: 'Amir', age: 36, country: 'France'},{name: 'Ms. Fluff', age: 4, vaccinated: true},];const youngOnly = filterBelowAge(peopleAndCats, 10);youngOnly.map(thing => thing.name);Result:
['Ms. Fluff']
That seems to work, but there are two problems. First, it doesn't actually solve the problem that we set up! We wanted a function that works for any object with an age property, but this one only works for users and cats.
Second, the
Array<User | Cat>return type is a big problem. If we usefilterBelowAgeon an array of users, we expect to get an array of users back. Unfortunately, we'll always get anArray<User | Cat>instead. Trying to use it as anArray<User>is a type error.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const users: User[] = [{name: 'Amir', age: 36, country: 'France'},];const youngOnly: User[] = filterBelowAge(users, 10);youngOnly.map(thing => thing.name);Result:
type error: Type '(User | Cat)[]' is not assignable to type 'User[]'. Type 'User | Cat' is not assignable to type 'User'. Property 'country' is missing in type 'Cat' but required in type 'User'. So much for that potential solution. Now let's sketch out what an ideal solution would look like.
We want a function that works with any type that has
age: number. The objects may have other properties too, but our function's body shouldn't be able to see those properties. From our function's perspective, everything should look like an{age: number}.However, our function should return the full type, not just
{age: number}. If we pass anArray<User>, we want to get anArray<User>back, not anArray<{age: number}>. If we pass anArray<Cat>, we want anArray<Cat>back. Likewise for any other type, including types that didn't exist when we wrote the function.When a function needs to work with any type, we know that it must involve some kind of generic. As a first attempt, we'll add a simple
<T>type parameter. The function now takes anArray<T>and returns anArray<T>. Unfortunately, nothing in that type says "theseTs will have anageproperty", so trying to access.ageis a type error.>
function filterBelowAge<T>(things: Array<T>, limit: number): Array<T> {return things.filter(thing => thing.age < limit);}Result:
type error: Property 'age' does not exist on type 'T'.
To solve this problem, we need a new TypeScript feature: generic constraints. Constraints let us say "this can be any type... as long as it's compatible with this other type". In our case, we want our function to take and return arrays of any type that's compatible with
{age: number}.We do this by adding the generic constraint
<T extends {age: number}>. In TypeScript, we can always addextends ...to a generic type parameter. Anywhere we can write<T>, we can also write<T extends ...>.>
function filterBelowAge<T extends {age: number}>(things: Array<T>,limit: number): Array<T> {return things.filter(thing => thing.age < limit);}Now our function works! When we pass an array of users, we get an array of users back. But we can also pass cats and get cats back, and likewise for any other object type that has an age.
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
type User = {name: string, age: number, country: string};const allUsers: User[] = [{name: 'Amir', age: 36, country: 'France'},{name: 'Gabriel', age: 7, country: 'France'},];const youngUsers: User[] = filterBelowAge(allUsers, 10);youngUsers.map(user => user.name);Result:
['Gabriel']
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
type Cat = {name: string, age: number, vaccinated: boolean};const allCats: Cat[] = [{name: 'Ms. Fluff', age: 4, vaccinated: true},{name: 'Wilford', age: 17, vaccinated: true},];const youngCats: Cat[] = filterBelowAge(allCats, 10);youngCats.map(cat => cat.name);Result:
['Ms. Fluff']
Here's a mental model for thinking about generic constraints. They carve the world of types in half. There's the type world inside the function, and there's a separate type world outside.
Inside the function,
Talways has the propertyage: number. That forces us to write the function in a way that works for any object with an age. Using the type in any other way is a type error. In our case,T extends {age: number}doesn't say anything about anameproperty, so accessing.nameis a type error.>
function filterBelowAge<T extends {age: number}>(things: Array<T>,limit: number): Array<T> {return things.filter(thing => thing.name === 'Amir');}Result:
type error: Property 'name' does not exist on type 'T'.
The function body's types are only checked once. If the function type checks, then the compiler continues. But our function might be called in many places: sometimes with users, sometimes with cats, sometimes with buildings. Each of those calls is type checked separately.
Now we can see what generic constraints really do. They're a way to voluntarily constrain ourselves, so that we can only see a small piece of a type inside of the function. That allows our function to work with any type that has that piece. From the outside, it looks like our function was purpose-built for users or cats or buildings or companies. But in reality, it wasn't purpose-built for anything; it was written to work with any object that has an age.
As usual, everything here is safe and the static types are enforced. If we try to pass objects without an
age: numberproperty, that's a type error.>
function filterBelowAge<T extends {age: number}>(things: Array<T>,limit: number): Array<T> {return things.filter(thing => thing.age < limit);}type Album = {name: string, copiesSold: number};const allAlbums: Album[] = [{name: 'The Dark Side of the Moon', copiesSold: 24400000},];filterBelowAge(allAlbums, 10).map(album => album.name);Result:
type error: Argument of type 'Album[]' is not assignable to parameter of type '{ age: number; }[]'. Property 'age' is missing in type 'Album' but required in type '{ age: number; }'.We've seen that our
filterBelowAgefunction works, but what is the compiler actually doing when we call it? Here's a rough explanation, examining only the case where we pass anArray<User>.- The function's first parameter is declared as
Array<T>. We're passing anArray<User>, so TypeScript infers thatTin this case must beUser. - But
Tis declared as<T extends {age: number}>. DoesUsersatisfy{age: number}? Yes, it has the requiredageproperty, so we can continue. - The function's return type is
Array<T>. We know thatTisUser, so in this case the function returns anArray<User>. - We're assigning the function's return value to a
User[]variable. That's the same as the return type,Array<User>, so everything type checks.
- The function's first parameter is declared as
When we pass cats to
filterBelowAge, TypeScript follows exactly the same process, but with cats instead of users.When you start learning TypeScript, problems like the one in this lesson can seem daunting. "How can I write a function that returns a user when given a user, returns a cat when given a cat, etc.?" It can seem impossible, which leads us to reach for
any. But withany, we lose all type safety!With the generic constraints feature that we saw in this lesson, we solve that problem while retaining type safety. The function itself is fully type checked. When we call the function, that's fully type checked too. If we pass an array of users, we get an array of users back, with all of their properties intact.
Here's a code problem:
We're writing code to manage a veterinary office. So far, we have two types:
Animal, which has aspeciesproperty, andAppointment, which has anAnimal. TheAppointmenttype is generic: any animal can come in for an appointment.We also have a
getAppointmentSpeciesfunction: given an appointment, it should return the species of the animal coming in for that appointment. That function currently causes a type error: its generic constraint is missing. Add a generic constraint to fix that type error.(You don't need to change the body of the function here. You only need to add the generic constraint.)
type Animal = {species: string};type Appointment<A extends Animal> = {animal: A};function getAppointmentSpecies<A extends Animal>(appointment: Appointment<A>) {return appointment.animal.species;}const appointment = {animal: {species: 'felis catus',name: 'Ms. Fluff',},};const species = getAppointmentSpecies(appointment);species;- Goal:
'felis catus'
- Yours:
'felis catus'
Generic constraints are a critical feature of TypeScript, but they show up primarily in library or framework code. They're useful in situations like the one in this lesson, where we want to write code that relies on certain properties, but is also reusable in many situations.
Here are some examples where generic constraints are useful:
- A database library needs to work in any application, with any type of rows, but all rows must have a numeric ID.
<T extends {id: number}>. - An API server framework needs to work with any server-side API endpoint that its users write.
The endpoints can return any type of data, as long as the data is in JSON-compatible objects.
<T extends Json>. - Heaps (and some other data structures) can only store data if it has a "key".
Internally, the heap keeps data sorted by its key, but it doesn't need to access the rest of the data.
A heap can store any data with a key, just as our
filterBelowAgefunction could filter any array of things with ages.<T extends {key: number}>.
- A database library needs to work in any application, with any type of rows, but all rows must have a numeric ID.
We'll see more examples of generic constraints in future lessons.