TypeScript: Generics

Prerequisites:

Basic understanding of Typescript.

What are Generics/ Generic Types?

→ Generics pass some extra information that TypeScript fails to infer (i.e. guess). They allow you to continue working with data in TypeScript optimal way.

→ A generic is a type followed by another type.

For example, there is a variable of type array. Now array can hold data of any type i.e. string, number, etc.

→ It specifies the TS which type of data type it will hold, we can use it.

TRICK: Anything type written inside <> is generics.

A. Some built-in Generics:

  1. Array: If we know the array will hold a specific type (say string).

Here array is a type which is followed by string

const arr: Array <string> = [];

There is an old school way of defining in this case which is also mostly used:

const arr: string[] = [];

2. Promises: If we know promise will return a string.

Here the promise is a type followed by the string (i.e. this promise will return a string)

const promise: Promise<string> = new Promise((resolve, reject) => {  setTimeout(() => {    try {      resolve("Hola, data succeded.");    } catch (e) {      reject("Something went wrong " + e);    }  }, 2000);});promise.then(data => {  data.split(' ');});

→ As typescript now knows the result will be string, auto-completion of string function will also come in the editor.

After implementing generics
Before implementing generics

→ Typescript will not allow compiling if you accidentally return a different type. Hence generics is a very powerful feature of TypeScript.

B. Custom Generic Functions

→ Let us walk through an example of why we need the generic function

Eg1: Suppose we have a function that merges two objects.

function merge(obj1: object, obj2: object) {  return Object.assign(obj1, obj2);}const mergedResult = merge({name: 'Amir'}, {gender: 'male'});console.log(mergedResult); // O/P: get merged obj i.e. {name: 'Amir', gender: 'male'}

→ The above function will work fine and we will get the merged object.

Problem: When TypeScript tries to access the property of the merged object, it fails to infer it (i.e. guess it) and will throw an error.

TS only infers objects and not complete objects, hence the TS compiler will not run successfully.

→Thanks to Generics for saving us from such issues.

→ The standard accepted syntax is to use T and U variables. We can also use any name of our choice.

Eg2: Replace object type with T and U and also write as generics in the function.

function merge <T, U> (obj1: T, obj2: U) {  return Object.assign(obj1, obj2);}const mergedResult = merge({name: 'Amir'}, {gender: 'male'});// console.log(mergedResult); // get merged obj i.e. {name: 'Amir', gender: 'male'}console.log(mergedResult.name);

→ Now TS will be able to infer the properties of the merged objects and will compile without errors.

→ So the key idea is when writing in a generics way, it gives the intersection of the objects, hence the property is accessible.

T and U simply mean it can be of any type and typescript understands it.

→ If we want to be more specific, we can specify T and U at the time of calling

Eg3:

function merge <T, U> (obj1: T, obj2: U) {  return Object.assign(obj1, obj2);}// const mergedResult = merge({name: 'Amir'}, {gender: 'male'});const mergedResult = merge<string, number>('test data', 5); // better TS infer it// console.log(mergedResult.name);

Good practice: We should not write type explicitly, and let TS infers the o/p type.

C. Working with constraints:

→ This means to allow only specific types otherwise throw errors.

Eg. In the same eg we are passing one obj and one number. This function was supposed to merge two objects. TS silently ignores the second parameter. Development wise there is a mistake.

Eg4.

function merge <T, U> (obj1: T, obj2: U) {  return Object.assign(obj1, obj2);}const mergedResult = merge({name: 'Amir'}, 5);console.log(mergedResult); // O/P: {name: 'Amir'}, TS silently ignores number

→ To save developers from this type of mistake, constraints are very helpful.

→ A constraint is written by extends keyword followed by type expected

Eg.

function merge <T extends object, U extends object> (obj1: T, obj2: U) {  return Object.assign(obj1, obj2);}const mergedResult = merge({name: 'Amir'}, 5);console.log(mergedResult); // TS will force to pass obj
Compilation Error — from a logical mistake
Passes successfully: with both obj types

Eg2.1: Let us look at a different example with the interface as the type in function.

→ The interface is a concept introduced in TS. It is a contract which a function or a class must follow.

interface Lengthy {  length: number}function countAndDescribe <T extends Lengthy> (element: T) {  let descriptionText = 'Value not found.';  if(element.length === 1)    descriptionText = 'Got 1 element';  else if(element.length > 1)    descriptionText = `Got ${element.length} values`;  return [element, descriptionText];  }console.log(countAndDescribe('Hi there'));   // String accepted
console.log(countAndDescribe(["Sports", "Cooking"]));
console.log(countAndDescribe([])); // All types having length property is allowed

→ Here generics expect the type which matches the interface requirement. The interface allows any type that has length property inside it.

String, arrays both have a length property. TS will compile successfully. We can see now generics are robust.

→ If we try to pass number (which does not have length property)

TS Compilation Error — saying number are not allowed

→ So the idea here is generics do no care whether about which type of data is passed (i.e. array or string). It must have a length property

D. key-of in constraints:

→ Suppose we are trying to access the property of an object which does not exist. TypeScript will not throw errors and will return undefined.

→ To fix this during the developoment. the key-of constraint is used.

Eg1: Without generics:

function extractAndConvert(obj: object, key: string) {  return 'Value is ' + obj[key];}console.log(extractAndConvert({}, 'name')); // O/P: Value is undefined

Eg2: With key-of :

function extractAndConvert<T extends object,U extends keyof T>(obj: T, key: U) {  return 'Value is ' + obj[key];}console.log(extractAndConvert({}, 'name'));

→ TS will now throw a compilation error to either add the specified property in the object or remove such code.

TS compilation error — When name property is not there
TS compilation passed — after adding name property

E. Generics types with Classes:

Let us see an example:

Classes without Generics

→ TS complains about the return type of items. For this, we will use Generics in classes.

Eg3

class DataStorage<T> {  private data: T[] = [];  addItem(item: T) {    this.data.push(item);  }  removeItem(item: T) {    this.data.splice(this.data.indexOf(item), 1);
}
getItem() { return this.data; }}const textStorage = new DataStorage<string>();
textStorage.addItem("Amir"); // Adding data
textStorage.addItem("Nasir");textStorage.addItem("Ronith");textStorage.removeItem("Amir"); // Removing dataconsole.log(textStorage.getItem()); // Getting data

→ The above code works smoothly for the primitive type data (i.e. numbers, string, etc). There is some problem with objects

→ We should always set an object in a variable and then use it everywhere as objects are reference type.

→ We can also use constraints flexibly

class DataStorage<T extends string | number | boolean> {  // class data
}

NOTE: Generic will only allow to chose user you can use either of above types i.e. string, number, or boolean. Once push data in an array of a specific type. It will not allow adding of a different type.

KEY TO NOTE:

The specific type is allowed

or any type which has a length to be allowed (as we have seen in the previous eg.)

F. Some utility Generic function:

→ We have already seen some utility functions (i.e. built-in functions ) in the beginning of this blog. i.e. Array and promises. Lets us see a few more

3. Partial:

Eg1: Suppose we have a function, whose return type is an interface. Below code will Pass TS compiler:

→ The function expects return type of function should have all interface mentioned types which work fine when we returned at once.

interface CourseGoal {  title: string;  description: string;  completeUntil: Date;}function courseGoal(  title: string,  desscription: string,  date: Date): CourseGoal {  return { title: title, description: desscription, completeUntil:      date };  when we return in obj created. in fly will work fine}

Eg2: Suppose there is a scenario where you do not want to return an object in fly and add step by step in, then return. After doing some validations.

interface CourseGoal {  title: string;  description: string;  completeUntil: Date;}function courseGoal(  title: string,  description: string,  date: Date): CourseGoal { // After parameter - : means return type of functionlet courseGoal = {};// some logic to be handled first (say business requirement).. eg validation
courseGoal.title = title; // TS will throw Error
courseGoal.description = description;courseGoal.date = date;}

→ TS will throw an error because it is expecting all the property to be there at once.

→ With the help of partials, we can tell TS to ignore type checks now. After adding all property, we will change the type to the expected type i.e. CourseGoal.

Eg2.2: With Partials:

interface CourseGoal {   title: string;  description: string;  completeUntil: Date;}function courseGoal(  title: string,  description: string,  date: Date): CourseGoal {let courseGoal: Partial<CourseGoal> = {}; // Added partial - TS will ignore type check// some logic.. eg validation
courseGoal.title = title;
courseGoal.description = description;courseGoal.completeUntil = date;return courseGoal as CourseGoal; // Reverted back to original type}
TS Compilation Passed

4. ReadyOnly generics:

→ Suppose we want to lock an array so that in future code cannot be modified, we can use this utility function.

Eg: With Readonly:

const names: Readonly<string[]> = [“Amir”, “Nasir”, “Ronith”];names.push(“Farhin”);console.log(“names”, names);
TS will not allow modifying read-only type array

Difference between Generics and Unions:

→ There is often confusion between the usage of generics and unions.

Union: When we define unions — it is a mixture of what we insert — say string or number or boolean in an array or obj.

Generics: It will only allow user to chose either of the above types i.e. string, number, or boolean. Once push data in an array of a specific type. It will not allow adding of a different type.

TRICK:

Union types are helpful when you want to choose a mixture of types.

Generics are helpful when you want to lock to specific types i.e all of string or object or any type having length etc.

Closing thoughts:

Generic give TypesScript more flexibility combines with type safety. It also prevents some unseen logical mistake which we as developer can make accidentally.

Thank you for being till the end 🙌 . If you enjoyed this article or learned something new, support me by clicking the share button below to reach more people and/or give me a follow on Twitter to see some other tips, articles, and things I learn and share there.

Web Artisan. Human — Engineering