Skip to main content

Typescript: How to use generics to make your functions smarter

· 6 min read
Conor Strejcek

Generic Function

Make your code safer and easier to work with.

note

This post is geared towards developers with limited Typescript experience who would like to learn how to use some of the more powerful language features. If you are unfamiliar with Typescript syntax, take a few minutes to look over the basics here.

Why use a generic?

There are many reasons to use generics, but on a high level, they are used to make your code more "generic" in the sense that the same code can operate over multiple types. That's great, but it can sometimes be difficult to identify these scenarios if you've never implemented them yourself. One easy way to quickly identify a function which could be improved through conversion to a generic is by taking a look at the function's signature; if the function:

  1. Takes arguments of multiple types
  2. Returns different types depending on the supplied arguments

then it's likely a good candidate for implementing a generic. In this article, I will go through the process of refactoring one such function. Once you get the hang of it, generics can make your code safer and easier to work with.

An example of a function which operates over multiple types

First, take a look at the code below:

// Type Definitions

enum IngredientName {
TOMATO = 'tomato',
MILK = 'milk'
}

interface Tomato {
weight: number;
color: 'red' | 'green';
name: IngredientName.TOMATO;
price: number;
}

interface Milk {
size: 'gallon' | 'half-gallon';
fatContent: '1%' | '2%' | 'whole' | 'skim';
name: IngredientName.MILK;
price: number;
}

type Ingredient = Tomato | Milk;
// Our getIngredient function
function getIngredient(ingredientName: IngredientName): Ingredient {
// Do something to fetch the ingredient from an API or DB
return ingredient;
}

// Somewhere else
const milk = getIngredient(IngredientName.MILK);
const tomato = getIngredient(IngredientName.TOMATO);

In this example, we have defined a function called getIngredient, which takes in an IngredientName as an argument, and returns an Ingredient. However, as written, the inferred type for milk and tomato will both be Ingredient, and we won't get the benefit of the compiler knowing that milk has a size property, or that tomato can have a color. If you attempt to access these properties without narrowing, you'll get an error like this:

const milk = getIngredient(IngredientName.MILK);

console.log(milk.size);
// Property 'size' does not exist on type 'Ingredient'.
// Property 'size' does not exist on type 'Tomato'. ts(2339)

This is because the Typescript compiler only knows that an Ingredient will be returned from getIngredient, so only properties which exist across all types in the union Tomato | Milk can be safely accessed (price or name).

We can improve this with generics

When converting a function to a generic, it can help to first identify which incoming arguments have an impact on the return type from your function. In this scenario, we know that the ingredientName will directly affect what is returned, so we want to capture its type. We can use type variables to capture the type of the ingredientName parameter:

note

Before moving on, a quick note about type variable naming conventions. In many examples (including the Typescript documentation), type variables are defined using a single character convention first established in the Java documentation. In this case, that would look like getIngredient<T> instead of getIngredient<TIngredientName>. However, these conventions were based on the difficulty of differentiating a type variable and a regular variable, which is not really an issue if you use a modern IDE. In this post, I will instead be using the convention of prefixing type variables with a "T."

// Using a type variable

function getIngredient<TIngredientName>(
ingredientName: TIngredientName
): Ingredient {
// Do something to fetch the ingredient from an API or DB
return ingredient;
}

const milk = getIngredient(IngredientName.MILK);
// milk will have the `Ingredient` type

Here, we have defined a type variable with the syntax <TIngredientName>, and we have defined the type of the ingredientName argument to use this type. However, we've now lost some information, because we are no longer limiting ingredientName to be of the type IngredientName. To do this, we can use a generic constraint:

// Adding a type constraint

function getIngredient<TIngredientName extends IngredientName>(
ingredientName: TIngredientName
): Ingredient {
// Do something to fetch the ingredient from an API or DB
return ingredient;
}

Using the extends keyword allows us to narrow the type for TIngredientName to the values from the IngredientName enum. It might look like we're back where we started, but now that we have access to the type of the passed argument, we can use it to define the return type of our function. To do this, we will define a mapping from IngredientName to Ingredient:

// Mapping IngredientName to Ingredient

interface IngredientMap {
[IngredientName.TOMATO]: Tomato,
[IngredientName.MILK]: Milk
}

How will this help us? Well, we can use an Indexed Access Type to reference properties of IngredientMap like this:

type SomeType = IngredientMap[IngredientName.MILK];
// SomeType === Milk

With this pattern, we can use our captured type to look up the property, and assign another type variable:

function getIngredient<
TIngredientName extends IngredientName,
TIngredient = IngredientMap[TIngredientName] // Adding a new type variable, TIngredient
>

Here, we use the = operator to set a default value for the TIngredient type variable, and use the TIngredientName type variable we already defined to access the type within IngredientMap. Putting this together with the rest of the function, we get the following:

function getIngredient<
TIngredientName extends IngredientName,
TIngredient = IngredientMap[TIngredientName]
>(
ingredientName: TIngredientName
): TIngredient {
// Do something to fetch the ingredient from an API or DB
return ingredient;
}

const milk = getIngredient(IngredientName.MILK);
const tomato = getIngredient(IngredientName.TOMATO);

console.log(tomato.color); // ✓ No errors!
console.log(milk.size); // ✓ No errors!
console.log(milk.color); // Property 'color' does not exist on type 'Milk'. ts(2339)

In this new version of the function, we use the captured TIngredientName to look up the associated type in our map with TIngredient = IngredientMap[TIngredientName]. Then, we can use TIngredient as the return type for our function. After these changes, values returned from this function will now have the correct specific type of Milk or Tomato, based on the argument supplied.

Next steps

Here are a couple other ways to identify potential use-cases for generics:

  • Multiple functions which accomplish the same thing for different types → combine into a single generic
  • Functions which require the use of type guards, if the type of incoming arguments are known → try refactoring into a generic to narrow the return type

Now that you can identify some functions which can benefit from generics, try implementing them within your own code. Some generic implementations may be simpler than this example, and some could be far more complicated; if you need more detailed information on the different possibilities, take a look at the documentation for more examples.