Understand TypeScript Generics

Understand TypeScript Generics

It is one of the most critical things in software development to create components that are not only well-defined and consistent but also reusable. Components that are capable of working on the data of today, as well as the data of tomorrow, will give you the most flexible capabilities for building up large software systems.

The purpose of this article is to demonstrate how TypeScript generics can be applied to functions, types, classes, and interfaces to make them dynamic and reusable.

Generics in Typescript.png

WHAT EXACTLY IS GENERIC?

In languages like TypeScript, C#, and Java, one of the main tools in the toolbox for creating reusable components is generics, that is, being able to create a component that can work over a variety of types rather than a single one. This allows users to consume these components and use their types.

Generics is a tool in TypeScript that allows users to create reusable structures by passing types as parameters to functions, types, classes, and interfaces. This structure can work with a variety of data types rather than just one. It ensures long-term scalability as well as flexibility for the program.

SETUP A PROJECT:

To run the following code snippets, you need to have typescript installed globally. If you don't have typescript installed, run npm install -g typescript in your terminal and check out my blog on Introduction to TypeScript to set up a typescript project required for this article.

INTRODUCTION TO GENERICS:

Whenever you create a generic structure, you must use <> to wrap the parameters that the generic structure accepts.

The following is the syntax for creating a generic structure:

SYNTAX:

function example<T>(arg: T): T {
  return arg;
}

For the moment, we have declared a generic function (we will delve into generic functions in upcoming topics in this article).

To the example function, we have added the type variable T. The T allows us to capture the type that the user provides as an argument (e.g. number) for later use. (T stands for Type, and is commonly used as the first type variable name when defining generics, but it can be replaced with any valid name.)

This function's return type and argument type are dependent on what we pass in while calling it.

Check out the following code to see how we can pass generic-type arguments:

const returnedValue = example<string>('hello');

string was passed as a TYPE argument to the generic example function.

Let's hover over the returnedValue variable and look at the return type:

image.png

The return type is a string, which we passed as a type argument.

ADDS STRICT TYPE CHECKING:

Generics have strict type-checking. Our code above passes a string as a type argument, so we must pass a string in the function argument as well.

In the case of any other type, we will encounter the following compilation error:

image.png

This is the beauty of generics in typescript because they are dynamic, yet strictly typed at the same time.

ACCEPTS ANY VALID TYPE:

As opposed to passing string as a type argument in the above example, we can also pass any valid type or interface as follows:

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

function example<T>(arg: T): T {
  return arg;
}

// return type will be Person
const returnedValue = example<Person>({
  name: "Max",
  age: 30,
});

As you can see, we have a Person interface which is a valid type in TypeScript.

It is passed as a type argument to the example function, meaning the function argument must be of type Person.

If we attempt to do anything outside the rules, we will get a compilation error:

image.png

BUILT-IN GENERICS:

In addition to creating our own generics, TypeScript has some built-in generics that can be used.

The following are some generics that TypeScript offers:

Array<T> TYPE:

My previous articles have discussed this Array type and referred to it as a generic type.

It is probably one of the most commonly used generics in TypeScript.

Here's how to declare a variable's type using the Array keyword:

const names: Array<string> = ["John", "Jane", "Mary"];

Here, we have a variable called names, and we have declared it as an Array<string> (array of strings). In other words, it's the equivalent of string[], which we have used in the past.

What happens if we don't pass string as a type argument to the Array type?

image.png

It says Generic type 'Array<T>' requires 1 type argument(s). This means we need to pass a type argument to the Array keyword.

What happens if we pass a different type of value in an array of type Array<string>?

image.png

Similar to our example function, this compiles with an error if we pass a different type value in a generic structure.

Promise<T> TYPE:

Promise is another built-in generic type. It expects only one type argument.

If you want to learn more about promises, check out my detailed article on Promises in JavaScript.

Let's take an example to better understand the Promise type. Look at the following code.

const _promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Success!");
  }, 2000);
});

_promise.then((data) => {
  console.log(data);
});

The above code creates a _promise variable, which is a Promise that returns a string when it is resolved.

Our promise is then triggered with a .then() block and the resolved value is captured in the data variable.

Let's hover over the _promise variable and see what type is inferred:

image.png

The type shown is Promise<unknown>, which is not beneficial since we know that it will resolve to a string.

Therefore, to make it happen, we can use Promise type and pass string as a type argument to it as follows:

const _promise: Promise<string> = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve("Success!");
  }, 2000);
});

_promise.then((data) => {
  // type of data is string
  console.log(data);
});

Now, if we add . in front of data in the .then() block, we will get autosuggestions of all string methods available:

image.png

We will see how we can use Promise and Axios to write scalable API functions in the next part of this article.

Readonly<T> TYPE:

Readonly is a generic, built-in type that allows us to throw errors whenever a method that alters or mutates the original structure is used.

It constructs a type with all properties of T set to readonly. See for yourself:

let x: Readonly<number[]> = [1, 2, 3];

x.push(4); // error
x.length = 0; // error
x[0] = 12; // error

x.map((v) => v); // without error
x.filter((v) => v > 1); // without error
let y: Readonly<{
  name: string;
  age: number;
}> = {
  name: "John",
  age: 30,
};

y.age = 20; // error!
y.name = "John"; // error!

When you want an array/object that never changes, this is useful.

Partial<T> TYPE:

It constructs a type with all properties of T set to optional.

Take a look at the following example:

interface Course {
  title: string;
  price: number;
  description: string;
  rating: number;
}

// same as { title?: string; price?: number; description?: string; rating?: number; }
let course: Partial<Course> = {};

Partial will make all the properties of the Course interface optional.

NonNullable<T> TYPE:

NonNullable<T> prevents you from passing null or undefined to your structure. This complements the strictNullChecks compiler flag in the tsconfig.json file, so make sure you activate it (ignore it if you have strict flag set to true):

Let's use NonNullable in our example function code.

function example<T>(arg: T): T {
  return arg;
}

let a = example<string | null>(null); // runs without error

Now the type argument is a string | null union type. The code above executes without error.

We'll see what happens with NonNullable:

function example<T>(arg: NonNullable<T>): T {
  return arg;
}

let a = example<string | null>(null); // throws error

Even though a null could be passed to our generic example function as a function argument, the NonNullable type internally discards it.

GENERIC FUNCTIONS:

We looked at how we can accept type as a parameter in the first topic of this article by using the example function.

Here are some more complex examples of generic functions.

FUNCTION TO MERGE TWO OBJECTS:

Suppose we want to create a merge function that takes two arguments, both are objects and returns the merged object.

function mergeObjects(obj1: object, obj2: object) {
  return { ...obj1, ...obj2 };
}

const mergedObj = mergeObjects({ name: "Max" }, { age: 30 });

We have the mergeObjects function here, which merges the obj1 and obj2 of type object and returns the merged object.

Then we call the function with some arguments and store the returned data in the mergedObj variable.

Let's hover over the mergedObj variable and see what the inferred type is.

image.png

We have {} (an object), which is not beneficial, because when we access the age property on the mergedObj variable, we will receive a compilation error as follows:

image.png

But as a developer, we know that if we merge { name: "Max" } and { age: 30 }, we should get { name: "Max", age: 30 } in return.

To accomplish this, let's convert this function into a generic function.

CONVERT IT TO A GENERIC FUNCTION:

function mergeObjects<T, U>(obj1: T, obj2: U) {
  return { ...obj1, ...obj2 };
}

const mergedObj = mergeObjects({ name: "Max" }, { age: 30 });

The mergeObjects method accepts two type parameters T and U. These type parameters are assigned to obj1 and obj2 respectively.

Now, if we call the mergeObjects function with the same function arguments. T and U will automatically become the type inferred from the passed arguments. (We don't have to specify the type for T and U, TypeScript automatically manages that for us)

Thus, T will be assigned as {name: string} and U will be assigned as {age: number}.

If we hover over the mergedObj variable we will see the following inferred type:

image.png

The mergedObj variable got inferred as an intersection type including {name: string} and {age: number}. (To learn more about intersection types, click here)

Now if we try to access the age property on the mergedObj variable, we will get autosuggestions from the IDE and will not get compilation errors, as follows:

image.png

IT IS DYNAMIC:

A generic function is useful because now you can pass literally any key in the object, and the function will automatically detect which keys are available in the merged objects.

Here's an example containing more keys in one of the arguments of the mergeObjects function.

function mergeObjects<T, U>(obj1: T, obj2: U) {
  return { ...obj1, ...obj2 };
}

const mergedObj = mergeObjects(
  { name: "Max", skills: ["typescript", "javascript"], salary:"5000" },
  { age: 30 }
);

Let's see what the autosuggestions have to offer:

image.png

Generic functions are great for this.

GENERIC CONSTRAINTS:

There is a problem with the above mergeObjects function. The compiler will not throw an error if we pass any type of value as an argument.

Look at the following image:

image.png

This is a problem because only and only objects should be allowed as arguments to the mergeObjects function to avoid unexpected results.

To do so, we can use generic constraints to add more type-checking.

extends KEYWORD:

So our logic is that we want to make mergeObjects generic, but we can only accept arguments of type object.

To accomplish that, we can do the following:

function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U) {
  return { ...obj1, ...obj2 };
}

const mergedObj = mergeObjects({ name: "Max", age: 20 }, 30); // throws error

console.log(mergedObj);

By using the extends keyword, we tell TypeScript that the mergeObjects function can accept any type of argument, but it must be of type object.

Now if we try to pass number as an argument, we get a compilation error:

image.png

keyof KEYWORD:

Let's use an example to understand the use case for the keyof keyword:

function getValueFromObj<T extends object, U>(obj: T, key: U) {
  return obj[key];
}

getValueFromObj({ name: "John" }, "name");

In the above function, we are accepting two parameters obj and key. obj is of T type which extends the object type, and key is of U type. Next, we are returning the value from the obj object by using the key parameter.

There is a compilation error here since TypeScript has no idea whether that key is present in the obj or not.

For the above code to be valid, we can use the keyof keyword to only accept the key which exists inside an obj object.

function getValueFromObj<T extends object, U extends keyof T>(obj: T, key: U) {
  return obj[key];
}

getValueFromObj({ name: "John" }, "name");

We are adding code here to make U only accept keys that are contained within the obj object.

Passing a key that does not exist in a passed object will cause a compilation error as follows:

image.png

GENERIC CLASSES:

Generic classes have a generic type parameter list in angle brackets (<>) following the name of the class as follows:

class ClassName<T> {}

MyStorage CLASS EXAMPLE:

Let's create a class that stores an array of data and performs operations on that data.

Check out the following code:

class MyStorage {
  private storedData: any[] = [];

  public setItem(value: any) {
    this.storedData.push(value);
  }

  public removeItem(index: number) {
    this.storedData.splice(index, 1);
  }

  public getItems() {
    return this.storedData;
  }
}

In the previous blogs, we learned that we should never use any keyword unless there are no other options, and no type definitions are available for the piece of code you're working on.

The above MyStorage class can be converted to a generic class to make it more strongly typed and dynamic.

MAKE MyStorage CLASS GENERIC:

Check out the following code.

class MyStorage<T> {
  private storedData: T[] = [];

  public setItem(value: T) {
    this.storedData.push(value);
  }

  public removeItem(index: number) {
    this.storedData.splice(index, 1);
  }

  public getItems() {
    return this.storedData;
  }
}

We have a MyStorage class that is generic, accepting a type parameter T, and has storedData property that is of type T[] (an array of the type T).

Additionally, it has setItem, removeItem, and getItems methods.

Let's create an instance of it.

class MyStorage<T> {
  private storedData: T[] = [];

  public setItem(value: T) {
    this.storedData.push(value);
  }

  public removeItem(index: number) {
    this.storedData.splice(index, 1);
  }

  public getItems() {
    return this.storedData;
  }
}

const stringStorage = new MyStorage<string>();

For stringStorage, the T will be a string. Therefore, the storedData property of the MyStorage class will be of type string[].

Let's add some items to our storage now.

const stringStorage = new MyStorage<string>();

stringStorage.setItem("Hello");
stringStorage.setItem("World");
stringStorage.removeItem(1);
console.log(stringStorage.getItems()); // ["Hello"]

Everything works fine!

IDE SUPPORT:

Similarly, we can create storage for complex types using the MyStorage class.

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

const employeeStorage = new MyStorage<Employee>();

employeeStorage.setItem({ name: 'John', age: 30 });
employeeStorage.setItem({ name: 'Alex', age: 29 });

let employees = employeeStorage.getItems();
console.log(employees); // prints [{name: 'John', age: 30}, {name: 'Alex', age: 29}]

If we try to operate on the employees variable, we will receive the following autosuggestions from the IDE:

image.png

STRICTLY TYPED AND FLEXIBLE:

Once you specify what type of data the MyStorage class preserves, you cannot pass different types of arguments to its methods. If you do so, you will receive an error such as the following:

image.png

This is the beauty of a generic class. It is flexible and strictly typed at the same time.

GENERIC INTERFACES:

We can implement generic interfaces in a similar way to generic classes. It almost works the same way.

To understand the syntax, let's look at an example:

interface KeyPair<T, U> {
  key: T;
  value: U;
}

let kp1: KeyPair<number, string> = { key:1, value:"Steve" }; 
let kp2: KeyPair<number, number> = { key:1, value:12345 };

As you can see in the above example, by using the generic interface as type, we can specify the data type of key and value.

In a similar way to classes, we can pass dynamic types to the interface. So, let's see how we can use generics in a real-world project to make the most of it.

GENERICS WITH Axios AND React:

Axios is a simple promise-based HTTP client for the browser and node.js. It provides a simple-to-use library in a small package with a very extensible interface.

Most commonly, it is used when working with React applications. Let's see how generics can be used to make the most of API calls.

SETUP A React AND TypeScript PROJECT:

Run the following command in your terminal to set up a React TypeScript project:

npx create-react-app react-axios-typescript --template typescript --use-npm
  • By using the --template typescript flag, a react project with preinstalled typescript and tsconfig.json configuration will be generated.

  • The --use-npm flag is optional. If you're using yarn and npm at the same time. And you want to tell create-react-app to use npm instead of yarn to generate the project.

Open the generated project in VS Code.

INSTALL & CONFIGURE axios:

Use the following command to install axios (because axios comes with built-in type declarations, so we don't have to install @types/axios separately)

npm install axios

Now, create the api/index.ts file inside the src folder. The folder structure should look like this:

image.png

Let's configure our axios client and create the ApiService class to write API calls.

In your api/index.ts file, add the following code:

import axios from "axios";

const axiosClient = axios.create({
  // url which is prefixed to all the requests made using this instance
  baseURL: "https://jsonware.com/api/v1/json"
});

class ApiService {
  static async getCourseById(id: string) {
    return axiosClient.get(`/${id}`);
  }
}

export default ApiService;

In the code above, we have created an axios instance axiosClient with baseURL (baseURL is the URL that will get prefixed to all requests sent via axiosClient. Whenever building a large-scale application, this is a good practice to create multiple instances).

Furthermore, we created a class ApiService which will hold all the static methods for API calls. (To learn more about static methods/properties in typescript, click here)

getCourseById is an asynchronous method that uses axiosClient for an API call.

As you hover your cursor over the getCourseById method, you will see its return type is as follows:

image.png

It is Promise<AxiosResponse<any, any>>.

Now, let's break this!

I have explained that the Promise is a generic type in one of the previous topics in this article.

In other words, this method returns a Promise that, when resolved, will return a response of the type AxiosResponse<any, any>.

AXIOS BUILT-IN GENERIC INTERFACE FOR RESPONSE:

Let's now look at what AxiosResponse<any, any> is.

It is a built-in interface provided by the Axios library. Let's look at its declaration and see how it's structured.

image.png

As you can see in the image above. AxiosResponse is a generic interface that expects two type parameters, T and D, which are by default assigned to be of any type.

T is assigned to the data key and D is assigned to the config key. Let's ignore the config key declaration for now since we generally don't modify it.

When we receive a response from the backend, it is generally accessible through the data key.

Therefore, we can create a custom interface for the data key and pass it as the first type argument to AxiosResponse.

Let's declare an interface that represents the response we receive from the back end.

To do that, create the file type/interface.ts in the src folder as follows:

image.png

Add the following code to type/interface.ts:

export interface ServerResponse {
  status: number;
  errorMessage: string;
  success: boolean;
  message: string;
  result: any;
}

The above interface shows the structure of a server response (which is set by the backend developers). As developers, we know that we will receive the following keys with the success response from the backend: (There might be different keys for different backends, it depends on how the backend response is structured)

  • status of type number.

  • errorMessage of type string.

  • success of type boolean.

  • message of type string.

  • result of type any because multiple responses may have different data types for the result key so it makes sense to use any here.

Now let's pass the above interface to AxiosResponse as the first type argument as follows:

...
class ApiService {
  static async getCourseById(
    id: string
  ): Promise<AxiosResponse<ServerResponse, any>> { // additional code
    return axiosClient.get(`/${id}`);
  }
}
...

RESPONSE AUTOSUGGESTIONS:

Let's try this method out in the App.tsx file and see what its benefits are.

Add the following code to the App.tsx file:

import React, { useEffect } from "react";
import "./App.css";
import ApiService from "./api";

function App() {
  const fetchCourseById = async () => {
    try {
      const response = await ApiService.getCourseById(
        "90794953-d58f-4cde-b2ce-9e0a6643c658"
      );
      console.log(response);
    } catch (error) {
      console.log(error);
    }
  };

  useEffect(() => {
    fetchCourseById();
  }, []);

  return (
    <div className="App">
      <h1>Learn typescript</h1>
    </div>
  );
}

export default App;

Let's see what the console displays.

image.png

If you look closely, you can see that the response structure is exactly the same as that of the AxiosResponse interface.

image.png

Let's see if we get suggestions for the data key for this AxiosResponse in the fetchCourseById function.

image.png

Yes, we got the autosuggestions because the data key in AxiosResponse is of type ServerResponse, which is our interface.

You can type your axios response this way if you have a common response structure coming from the backend. So that you can eliminate errors caused by accessing a key that does not exist or is incorrect from the server response.

CONCLUSION:

  • In this tutorial, you explore generics as they apply to functions, interfaces, classes, and custom types. You also used generics constraints for additional type checking.

  • Each of these makes generics a powerful tool you have at your disposal when using TypeScript. Using them correctly will save you from repeating code over and over again, and will make the types you have written more flexible.

  • This is especially true if you are a library author and are planning to make your code legible for a wide audience.

Make sure to subscribe to our newsletter on https://blog.wajeshubham.in/ and never miss any upcoming articles related to TypeScript and programming just like this one.

I hope this post will help you in your journey. Keep learning!

My Website, connect with me on LinkedIn and GitHub