Table of contents
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.
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 havetypescript
installed, runnpm 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:
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:
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:
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?
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>
?
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:
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:
We will see how we can use
Promise
andAxios
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.
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:
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:
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:
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:
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:
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:
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:
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:
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:
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 preinstalledtypescript
andtsconfig.json
configuration will be generated.The
--use-npm
flag is optional. If you're usingyarn
andnpm
at the same time. And you want to tellcreate-react-app
to usenpm
instead ofyarn
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:
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:
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.
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:
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 typenumber
.errorMessage
of typestring
.success
of typeboolean
.message
of typestring
.result
of typeany
because multiple responses may have different data types for theresult
key so it makes sense to useany
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.
If you look closely, you can see that the response structure is exactly the same as that of the AxiosResponse
interface.
Let's see if we get suggestions for the data
key for this AxiosResponse
in the fetchCourseById
function.
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!