Generics in typescript

ProfileAvatar

Omkar Nath

ยท

Generics are one of the most challenging topics that have haunted me for years. Well, you cannot blame me. I mean, just look at the below code. It looks absolutely bonkers! ๐Ÿ˜ตโ€๐Ÿ’ซ

By the end of this blog, we should be able to make sense of the below code. We will go through all the concepts needed to dismantle this code.

1 function process<T, U extends keyof T, V extends (args: T[U]) => any>(
2    input: T,
3    key: U,
4    transformer: V
5): ReturnType<V> {
6    return transformer(input[key]);
7}
8

Mental Model

The right mental model to understand generics is to think of them as functions where we can pass one or more type parameters, and that function returns a concrete nongeneric type

Let's take a look at the below code

1type Wrapper<T>={
2  value:T
3} 
4
5const NumberWrapper=Wrapper<number>; // {value:number}
6const StringWrapper=Wrapper<string>; // {value:string}
7
8

The Wrapper type takes a generic type T and returns a concrete, nongeneric type. The value of T in the returned type will be replaced with whatever was used while invoking this type.

Generic constraints

Let's take an example

1function mergeObjects<U,V>(obj1:U,obj2:V){
2  return {
3    ...obj1,
4    ...obj2
5  }
6}
7

The mergeObjects is a generic function that merges two objects. For example:

1  let person=mergeObjects({name:"Naruto"},{ age:17});
2
3  console.log(result)
4

Output:

1{ name: "Naruto" , age:17}
2

Everything looks okay, but the merge function expects two objects. However, it doesn't prevent you from passing a non-object like this:

1  let person = mergeObjects({name:"Naruto"},25)
2
3  console.log(person)
4

Output

1  {name: "Naruto"}
2

Now let's look at the modified solution

1  function mergeObjects<U extends object,V extends object>(obj1: U, obj2:V){
2      return {
3          ...obj1,
4          ...obj2
5      }
6  }
7

Conditional Types

Now we will look at another cool feature in generics, which is Conditional Types. Conditional Types allows us to create dynamic types based on conditions. The syntax should definitely ring some ๐Ÿ””

1T extends U ? X: Y
2

Now let's take an example to understand the use case. Suppose you have a Mixed type like below, and you want to extract only string types from it. Recall the mental model, think of FilterStrings as function which takes a Generic type and returns some concrete type

1  type FilterStrings<T> = T extends string ? T : never;
2  type MixedTypes= string | number | boolean | "someRandomString";
3
4  type OnlyStrings=FilterStringTypes<MixedTypes>; // string | "someRandomString"
5

Now let's do something creative, since we can make use of extends now. How about we create a Type which can extract the length of the array. Since, all array like objects contains "length" property so we can just return it right ๐Ÿ˜Ž

1 type LengthOfArray<T extends any[]> = T["length"];
2
3 type len=LengthOfArray<[1,2,3]> // Returns 3 
4

Infer

The infer keyword compliments conditional types and cannot be used outside an extends clause. Infer is basically used without conditional types to declare a type variable within our constraint to capture types dynamically within extends clause of a conditional type.

Let's take an example of built-in TypeScript types like

1 Parameters<T>  type extracts the type of paramerters which a function accepts.
2
3
4 function add(a: number,b:number):number{
5     return a+b;
6   }
7
8 type ParamsOfAdd=Parameters<typeof add>; // [number,number]
9
10

Now let's try to implement our own version of Parameters that will give us clear idea of infer.

1  type MyParamExtractType<T extends (...args:any) => any > = T extends (...args: infer ParameterType) => any ? ParameterType : never; 
2

In above code, we are first checking if the passed type is any function . See condition types above for this. Now inside args we are kind of extracting the ParameteresType with infer keyword and returning it if the passed type was a function,else we simply return never.

Now let's try to implement ReturnType which is also very similar.

1 type MyReturnType<T extends (...args:any)=>any> = T extends (...args:any)=> infer ReturnT ? ReturnT :any; 
2

Mapped Types

Mapped Types are based on the principle of DRY (Do not repeat yourself). Let's again take an example

Suppose you have a type which contains some properties and you want to use the same properties in some other type. Probably to change the type of data-type they can accept.

1type FeatureFlags={
2  darkMode:()=>void;
3  newModal:()=>void;
4}
5

Now we want to create a type with same properties but it should be boolean instead of function.

1  type OptionsFlags<Type> = {
2    [Property in keyof Type]:boolean;
3  }
4

In this example, OptionsFlags will take all the properties from the type Type and change their values to be boolean.

1  type FeatureOptions=OptionsFlags<Features>
2 //type FeatureOptions = {
3 //   darkMode: boolean;
4//  newUserProfile: boolean;
5//}
6

I think now we have all the tools to understand the code at the beginning.

1 function process<T, U extends keyof T, V extends (args: T[U]) => any>(
2    input: T,
3    key: U,
4    transformer: V
5): ReturnType<V> {
6    return transformer(input[key]);
7}
8