Jay Gould

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:

Function without argument type

However if the argument type is defined, we get the autocomplete:

Function with argument type

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:

Generic function with argument type

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:

Generic function with 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:

Generic function with extends

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

Generic function with extends missing property

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 extending (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:

Generic interface with type 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:

Generic function with return type

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 out interface ISharedFoodProperties to a separate interface. This means we can use this more general types (getting the names and colors) rather than extracting types from the IChocolate and IFruits interfaces etc… separately.
  • The interface IChocolate now extends the interface ISharedFoodProperties types so we’re not repeating those types for each food type.
  • The type NamesAndColors is now using the TypeScript Pick utility function to create a new interface which takes the name and colour types from the ISharedFoodProperties interface. If we later update the name type to be an array, it would only need updating in the ISharedFoodProperties 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:

Error on generic function keyof function parameter

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 Picked 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:

Showing available parameters based in generic picked out properties

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

© Jay Gould 2023, Built with love and tequila.