DEV Community

Cover image for The ultimate explanation of TypeScript generics: functions
Milosz Piechocki
Milosz Piechocki

Posted on • Edited on • Originally published at codewithstyle.info

The ultimate explanation of TypeScript generics: functions

Originally posted on codewithstyle.info.

Recently I surveyed the readers of this blog to find out what TypeScript features people find difficult to understand. Generics were mentioned quite often. In this article, I'm going to equip you with a mental model that will let you understand generic functions properly (I'll focus on generic types in another article).

The concept of generics is not a very new one - it has been present in different programming languages (such as Java, C# or C++) for a long time. However, for folks without background in a statically typed language, generics might appear complicated. Therefore, I'm not going to make any assumptions and will explain generics completely from scratch.

Motivation

Let's say you are adding types to some JavaScript codebase and you encounter this function:

function getNames(persons) {
  const results = [];
  for (let person of persons) {
    results.push(person.name);
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

Typing this function is straightforward. It accepts an array of person objects as a parameter and returns an array of names (strings). For the person object, you can either create a Person interface or use one that you've already created.

interface Person {
  name: string;
  age: number;
}

function getNames(persons: Person[]): string[] {
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

Next, you notice that you don't actually need this function. Instead, you can use the built-in Array.map method.

const persons: Person[] = [
  /* ... */
];
const names = persons.map(person => person.name);
Enter fullscreen mode Exit fullscreen mode

Hmm, but what about types? You check the type of names and realize that it has been correctly inferred to string[]! How does TypeScript achieve such an effect?

To properly understand this, let's try to type the following implementation of map function.

function map(items, mappingFunction) {
  const results = [];
  for (let item of items) {
    results.push(mappingFunction(item));
  }
  return results;
}

const names = map(persons, person => person.name);
Enter fullscreen mode Exit fullscreen mode

The main issue with typing map is that you don't know anything about the type of the elements of the array it will be called with. What makes map so cool is that it works with any kind of array!

// Works with array of Persons
const names = map(persons, person => person.name);
// Works with array of names too
const uppercaseNames = map(names, name => name.toUpperCase());
// Works even with an array of numbers!
const evenNumbers = map([1, 2, 3, 4, 5], n => n * 2);
Enter fullscreen mode Exit fullscreen mode

Let's use any!

As a first step, let's try using any type to map this function.

function map(items: any[], mappingFunction: (item: any) => any): any[] {
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down. map has two parameters. The type of the first one (items) is any[]. We tell the type system that we want items to be an array, but we don't care about the type of those items. The type of the second parameter (mappingFunction) is a function that takes any and returns any. Finally, the return type is again any[] - an array of anything.

Did we gain anything by doing this? Sure! TypeScript now won't allow us to call map with some nonsensical arguments:

// 🔴 Error: 'hello' is not an array
map("hello", (person: Person) => person.name);
// 🔴 Error: 1000 is not a function
map(persons, 1000);
Enter fullscreen mode Exit fullscreen mode

Unfortunately, the types we provided are not precise enough. The purpose of TypeScript is to catch possible runtime errors earlier, at compile-time. However, the following calls won't give any compile errors.

// The second argument is a function that only works on numbers, not on `Person` objects.
// This would result in a runtime error.
map(persons, n => n + 5);
// We tell TypeScript that `numbers` is an array of strings while in fact it will be an array of numbers.
// The second line results in a runtime error.
const names: string[] = map(persons, person => person.age);
names[0].toLowerCase();
Enter fullscreen mode Exit fullscreen mode

How can we improve the typing of map so that above examples would result in a compile-time error? Enter generics.

Generic functions

Generic function is (in this case) a way of saying "this function works with any kind of array" and maintaining type safety at the same time.

function map<TElement, TResult>(
  items: TElement[],
  mappingFunction: (item: TElement) => TResult
): TResult[] {
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

We replaced any with TElement and TResult type parameters. Type parameters are like named anys. Typing items as TElement[] still means that it is an array of anything. However, because it's named, it lets us establish relationships between types of function parameters and the return type.

Here, we've just expressed the following relationships:

  • mappingFunction takes anything as a parameter, but it must be the same type of "anything" as the type of elements of items array
  • mappingFunction can return anything, but whatever type it returns, it will be used as the type of elements of the array returned by map function

The picture below demonstrates these relationships. Shapes of the same color have to be of the same type.

Generic  raw `map` endraw

You might have noticed the <TElement, TResult> thing that we added next to map. Type parameters have to be declared explicitly using this notation. Otherwise, TypeScript wouldn't know if TElement is a type argument or an actual type.

BTW, for some reason, it is a common convention to use single-character names for type parameters (with a strong preference for T). I'd strongly recommend using full names, especially when you are not that experienced with generics. On the other hand, it's a good idea to prefix type arguments with T, so that they're easily distinguishable from regular types.

Calling generic functions

How to call a generic function? As we saw, generic functions have type parameters. These parameters are replaced with actual types "when" the function is called (technically, it's all happening at compile-time). You can provide the actual types using angle brackets notation.

map<Person, string>(persons, person => person.name);
Enter fullscreen mode Exit fullscreen mode

Imagine that by providing type arguments TElement and TResult become replaced with Person and string.

Generic  raw `map` endraw

function map<TElement, TResult>(
  items: TElement[],
  mappingFunction: (item: TElement) => TResult
): TResult[] {
  /* ... */
}

// ...becomes...

function map(
  items: Person[],
  mappingFunction: (item: Person) => string
): string[] {
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

Having to provide type arguments, when calling generic functions would be cumbersome. Fortunately, TypeScript can infer them by looking at the types of the arguments passed to the function. Therefore, we end up with the following code.

const names = map(persons, person => person.name);
Enter fullscreen mode Exit fullscreen mode

Whoohoo! It looks exactly as the JavaScript version, except it's type-safe! Contrary to the first version of map, the type of names is string[] instead of any[]. What's more, TypeScript is now capable of throwing a compile error for the following call.

// 🔴 Error! Operator '+' cannot be applied to Person and 5.
map(persons, n => n + 5);
Enter fullscreen mode Exit fullscreen mode

Here is a very simplified sequence of steps that leads the compiler to throw an error.

  1. Compiler looks at the type of persons. It sees Person[].
  2. According to the definition of map, the type of the first parameter is TElement[]. Compiler deduces that TElement is Person.
  3. Compiler looks at the second parameter. It should be a function from Person to TResult. It doesn't know what TResult is yet.
  4. It checks the body of the function provided as the second argument. It infers that the type of n is Person.
  5. It sees that you're trying to add 5 to n, which is of type Person. This doesn't make sense, so it throws an error.

When to use generic functions?

The good news is that, most likely, you will not be creating generic functions very often. It's much more common to call generic functions then to define them. However, it's still very useful to know how generic functions work, as it can help you better understand compiler errors.

As exemplified by map, functions that take arrays as parameters are often generic functions. If you look at the typings for lodash library, you will see that nearly all of them are typed as generic functions. Such functions are only interested in the fact that the argument is an array, they don't care about the type of its elements.

In React framework, Higher Order Components are generic functions, as they only care about the argument being a component. The type of the component's properties is not important.

In RxJs, most operators are generic functions. They care about the input being and Observable, but they're not interested in the type of values being emitted by the observable.

Summary

Wrapping up:

  • generic functions let you achieve type safety for functions that work with many different types of inputs;
  • type arguments are very much like any type, except they can be used to express relationships between function parameters and the return type;
  • calling a generic function is very straightforward thanks to type inference.

I hope this article helped you finally understand generic functions. If not, please let me know!

Want to learn more?

Did you like this TypeScript article? I bet you'll also like my book!

⭐️ Advanced TypeScript ⭐️

Top comments (0)