Make your code safer and easier to work with.
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:
- Takes arguments of multiple types
- 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:
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.