Using generics in TypeScript with example
July 29, 2022
Generics can be added to functions, classes, types, and interfaces, and provide a way to create re-usable code that is typed. This means re-usable functions and classes, but generics can also be used to create re-usable types for use with TypeScript. This post requires some previous usage of basic TypeScript.
Generic functions with TypeScript, starting simple
Here’s a simple, plain JavaScript example of a function to get the names from an array of fruit objects:
const fruits = [
{ name: "Apple", colour: "Red", weight: 3 },
{ name: "Banana", colour: "Yellow", weight: 4 },
{ name: "Orange", colour: "orange", weight: 5 },
]
function getFruitNames() {
return fruits.map((fruit) => fruit.name)
}
console.log(getFruitNames())
// Output: [ 'Apple', 'Banana', 'Orange' ]
The above can be typed using TypeScript, assigning a type to the array of Fruit objects, and specifying that IFruit type as a the function argument type in getFruitNames
:
interface IFruit {
name: string
colour: string
weight: number
}
const fruits: IFruit[] = [
{ name: "Apple", colour: "Red", weight: 3 },
{ name: "Banana", colour: "Yellow", weight: 4 },
{ name: "Orange", colour: "orange", weight: 5 },
]
function getFruitNames(fruits: IFruit[]): string[] {
return fruits.map((fruit) => fruit.sdfs)
}
console.log(getFruitNames(fruits))
// Output: [ 'Apple', 'Banana', 'Orange' ]
The benefit TypeScript gives us here is the type safety for the fruits data. In the function definition for example, there’s not autocomplete if we don’t specify the argument type:
However if the argument type is defined, we get the autocomplete:
So far this function is typed, but it’s specifically for the use of fruits. The function getFruitNames(fruits: IFruit[])
specifies that the incoming data to the function will be an array of IFruit
type. Later, we decide we also need another function to get the names of chocolate:
interface IChocolate {
name: string
colour: string
weight: number
}
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
function getChocolateNames(chocolates: IChocolate[]): string[] {
return chocolates.map((chocolate) => chocolate.name)
}
console.log(getChocolateNames(chocolates))
Although this works and gives us typed data inside and returned from the getChocolateNames
function, it’s repeated a lot of the code from the fruits function. The object keys as defined in the interfaces for both sets of data is the same (name, colour, and weight). Sticking to DRY (don’t repeat yourself) principles is crucial for keeping a project easier to maintain, so with this in mind, we can re-work the function to be more generic for use with different types.
Here’s a generic function doing a similar thing as the ones above:
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
function getFirstFoodItem<T>(foods: T[]): T {
return foods[0]
}
console.log(getFirstFoodItem<IChocolate>(chocolates))
// Output: { name: "Cadburys", colour: "Purple", weight: 8 }
The function definition for getFirstFoodItem
function specifies it’s a geneirc function with the use of the <T>
just after the function name. This “T” is our type that will be passed in to the generic function. We know that the type we give will be an array of food, so we specify that in the argument with foods: T[]
. and we’re returning a single food item so we specify : T
as the return type.
The function call for getFirstFoodItem
can now make use of the getFirstFoodItem<T>
by passing in our type of IChocolate
. This tells the generic function “as we’re passing in a type of IChocolate
which has these keys (name, colour, weight), we can expect the data being added (the chocolates
variable) to be an array of chocolates, and return a chocolate item”, keeping the benefits of TypeScript:
Earlier we returned an array of names of the food items when the functions were non generic. This was easy as the function getFruitNames
knew explicitly that it would receive an array of fruits as the argument. Our generic type doesn’t though:
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
function getFoodNames<T>(foods: T[]): string[] {
return foods.map((food) => food.name) // <-- ERROR on food.name
}
console.log(getFoodNames<IChocolate>(chocolates))
This above code will output the following error:
This is because our generic function is not aware of the name
property. The whole idea of the generic function is that it doesn’t know the specifics of any given data set unless we tell the function to expect a certain piece of data:
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
function getFoodNames<T extends { name: string }>(foods: T[]): string[] {
return foods.map((food) => food.name)
}
console.log(getFoodNames<IChocolate>(chocolates))
Above we’re updating the generic function to expect the type being added to contain an object with a name
property which is a string. This means we get type safety inside the function:
Say later we accidentally take out the name property from our object. This could happen in a real project as data structures change sometimes. Our function will throw an error as it expects the name property to be there:
interface IChocolate {
colour: string
weight: number
}
const chocolates: IChocolate[] = [
{ colour: "Purple", weight: 8 },
{ colour: "Red", weight: 5 },
{ colour: "Orange", weight: 9 },
]
function getFoodNames<T extends { name: string }>(foods: T[]): string[] {
return foods.map((food) => food.name)
}
console.log(getFoodNames<IChocolate>(chocolates)) // <-- ERROR on IChocolate
It’s worth noting that TypeScript does infer types wherever possible. For example, with our generic type above, we could also call it like this:
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
function getFoodNames<T extends { name: string }>(foods: T[]): string[] {
return foods.map((food) => food.name)
}
console.log(getFoodNames(chocolates))
Note that there’s no IChocolate
passed in to the type area for the getFoodNames
function. TypeScript is inferring that the type being passed in is of type IChocolate
because that’s the type of the argument. It uses that as the T
generic type within the function itself.
I like to be explicit though, and add in the types wherever possible, as it makes it super clear for myself and other developers what’s going on, and even that the function is generic at all, in this example. I’ll leave it out for the rest of this post though, as it looks that little bit less clear what’s going on if you’re not too familiar with the syntax.
So that’s it for generic functions. What’s been covered:
- Creating generic functions allow re-use of similar functions but with different input data types, whilst still keeping the input and output of the function typed.
- A function will error if a type being specified does not match the type being used within the function, helping stop errors while developing.
- Types can be constrained by
extend
ing (as one example) to only accept certain data types to be used when calling the function.
Generic interfaces and types
As well as generic functions, generic types and interfaces can also be created. Because the syntax of generic types is very similar to that of generic functions, it can be confusing to determine what’s going on when there’s a lot of generics happening together.
Going back to our Fruits example - I’ll use this to explain why generics are useful for interfaces and types, and show the implementation after:
interface IFruit<T> {
name: string
colour: string
weight: T
}
const fruits: IFruit<number>[] = [
{ name: "Apple", colour: "Red", weight: 3 },
{ name: "Banana", colour: "Yellow", weight: 4 },
{ name: "Orange", colour: "orange", weight: 5 },
]
This turns our IFruit
interface in to a generic interface, as we’re allowing it to be re-used to pass in different types for the weight
property. When we define our fruits
const and give it a type of IFruit<number>[]
, we’re passing in the number
type to apply to the weight
property.
This could be useful in a situation where multiple data sets are being combined together, and some fruit is in the database as a number
type (i.e. 6
), and some is in as a string (i.e. "6kg"
). Now the IFruit
interface can be re-used. Without generics, we would have had to create two separate interfaces - IFruitWithNumberWeight
and IFruitWithStringWeight
for example.
If we try to pass in string
as the type to IFruit
, but the data contains number
types, it will tell us of the error:
Going back to our generic food functions from earlier, lets say at some point there’s a function which we create where we want to extract the name
and colour
values from the fruits
or chocolates
arrays we pass in (excluding the weight
values):
interface IChocolate {
name: string
colour: string
weight: number
}
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
interface NamesAndColors {
name: string
colour: string
}
function getFoodNamesAndColors<T extends NamesAndColors>(
foods: T[]
): NamesAndColors[] {
return foods.map((food) => {
return {
name: food.name,
colour: food.colour,
}
})
}
console.log(getFoodNamesAndColors(chocolates))
// Output: [{ name: 'Cadburys', colour: 'Purple' }, { name: 'Nestle', colour: 'Red' }, { name: 'Kinder', colour: 'Orange' }]
We use the <T extends NamesAndColors>
so the generic function knows to expect name
and colour
properties, which gives us types for use inside the getFoodNamesAndColors
function, and also gives us constraints on the data going in to the function.
By specifying an accurate return type, NamesAndColors[]
, it now gives us types after the function is called:
The above is valid TypeScript, but it can be improved to keep to those DRY principles. Note how the interfaces repeat the name
and colour
sub types:
interface IChocolate {
name: string
colour: string
weight: number
}
interface NamesAndColors {
name: string
colour: string
}
This may not seem like a big deal, but later down the line there will be a load more interfaces to manage. If the type of the colour
property changes at some point, we’d need to go back and update all types/interfaces which define the colour type. We want to have each type defined in as few places as possible - ideally only 1 place. This is where generics for interfaces and types are useful.
Rather than repeating these sub types, we can use TypeScript’s built in utility types, specifically the Pick
type, to help us craft the types we want:
interface ISharedFoodProperties {
name: string
colour: string
price?: number // Added to illustrate that NamesAndColors is using Pick to get only name and colour out
}
interface IChocolate extends ISharedFoodProperties {
weight: number
}
type NamesAndColors = Pick<ISharedFoodProperties, "name" | "colour">
function getFoodNamesAndColors<T extends NamesAndColors>(
foods: T[]
): NamesAndColors[] {
return foods.map((food) => {
return {
name: food.name,
colour: food.colour,
}
})
}
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
console.log(getFoodNamesAndColors(chocolates))
There’s a bit going on here, so breaking it down:
- As we’re making the
getFoodNamesAndColors
function to be used by all food types, I’ve broke outinterface ISharedFoodProperties
to a separate interface. This means we can use this more general types (getting the names and colors) rather than extracting types from theIChocolate
andIFruits
interfaces etc… separately. - The
interface IChocolate
now extends theinterface ISharedFoodProperties
types so we’re not repeating those types for each food type. - The
type NamesAndColors
is now using the TypeScriptPick
utility function to create a new interface which takes thename
andcolour
types from theISharedFoodProperties
interface. If we later update thename
type to be an array, it would only need updating in theISharedFoodProperties
interface.
Note how the syntax for Pick
is the same as the rest of the generics syntax, as Pick
is a built in type, which expects two arguments for the sole purpose of picking types out from other types.
Also note how we’ve changed the NamesAndColors
from being an interface to a type. I only really use interfaces to define the types for clear objects, but when using utility types or custom types combined together, it works better as a type:
type NamesAndColors = Pick<IChocolate, "name" | "colour">
See this incredible resource for more info on types vs interfaces.
Earlier we turned the IFruit<T>
interface in to a generic interface so we could pass in a type to the interface to set the weight
sub type. Assuming we wanted to keep that feature in our updated setup above, we could update to the following:
interface ISharedFoodProperties {
name: string
colour: string
price?: number // Added to illustrate that NamesAndColors is using Pick to get only name and colour out
}
interface IChocolate<T> extends ISharedFoodProperties {
weight: T
}
const chocolates: IChocolate<number>[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
Now the setup is looking better, but we could refactor this ever further and make it even more re-usable. At the moment our getFoodNamesAndColors
function does just that - it gets only the food and the names of the given food array. Sure, it’s typed well so the types of name
and colour
will always be correct, but what if we wanted to extract different properties later on? Rather than making a separate generic to handle weight
and price
(which we’d call something like getFoodWeightsAndPrices
probably), we could update the generic function to be even more generic (last refactor, I promise):
interface ISharedFoodProperties {
name: string
colour: string
price?: number
}
interface IChocolate extends ISharedFoodProperties {
weight: number
}
function getFoodProperties<T, K extends keyof T>(
foods: T[],
keys: K[]
): Pick<T, K>[] {
return foods.map((food) => {
let obj: any = {}
keys.forEach((key) => {
obj[key] = food[key]
})
return obj
})
}
const chocolates: IChocolate[] = [
{ name: "Cadburys", colour: "Purple", weight: 8 },
{ name: "Nestle", colour: "Red", weight: 5 },
{ name: "Kinder", colour: "Orange", weight: 9 },
]
console.log(getFoodProperties(chocolates, ["name", "weight"]))
The idea is we add in a food object, chocolates
(although it can be any food object matching our constraints in the function), and we can specify in the code which properties to pull out of the object.
The getFoodProperties<T, K extends keyof T>
tells the function that there are two generic types, T
and K
(not to be confused with the function arguments). These two parameters are infered by TypeScript as they are later assigned to be the types of the incoming function parameters here: (foods: T[], keys: K[])
.
With that in mind, we know that T
is the generic type for the foods
object (not the array of objects, which would be represented as T[]
), and K
is the generic type for the keys of the foods
object. In other words, T
is inferred to be the same as the IChocolate
interface, and K
is inferred to be an array of the keys of the IChocolate
interface.
This is extremely helpful, as it gives us strong typing on the keys in function parameter two, given a specific object in function parameter one:
Finally, going back to the function above, the return type is also specified as : Pick<T, K>[]
. This tells us the return type needs to be an array of foods
, which have has specific properties Pick
ed out, defined by the known K
type (i.e. an array of object keys as described at the start of the function).
The return type is great as we can now use the benefit of TypeScript for safety and code completion:
Thanks for reading
This post has been designed to start from a very simple implementation of TypeScript, and move up to a more advanced setup, in order to show the different ways to use generics in TypeScript. In a real life application you’d probably have much more structure than just functions, so in that case generics could be applied to classes as well.
Senior Engineer at Haven