Category: 08. TypeScript Generics

https://cdn3d.iconscout.com/3d/premium/thumb/dna-3d-icon-png-download-7414526.png

  • Generic Classes

    Generic Classes

    TypeScript Generic classes allow you to create a class that can work over a variety of data types rather than a single one. It increases the scalability and reusability of the code. Let’s understand how generic classes work in TypeScript.

    Syntax

    You can follow the syntax below to use the generic classes in TypeScript.

    classclass_name<T, U>{// Class body}let obj1 =newclass_name<data_type_1, data_type_2>();
    • In the above syntax, ‘class’ is a keyword to define a class.
    • ‘class_name’ is a valid identifier, representing the class name.
    • ‘<T, U>’ are type parameters specified in the angular bracket. You can specify as many as you want.
    • While defining the object of the class, you need to pass data types as an argument after the class name in the angular bracket.

    Example

    In the code below, we have defined the generic class named ‘Box’ which takes type parameter T.

    In the class, we have defined the ‘val’ variable of type T, and the constructor function which initializes the value of the ‘val’ variable.

    After that, we defined the getters and setters named get() and set(), respectively to get the value of the ‘val’ variable.

    Next, we have defined the ‘box1’ and ‘box2’ objects of the Box class which take number and string data type as a type parameter argument, respectively.

    // generic classclassBox<T>{// member variable
    
    val:T;// constructor with valueconstructor(value:T){this.val = value;}// Method to get valueget():T{returnthis.val;}// Method to set valueset(value:T):void{this.val = value;}}// create object of Box classlet box1 =newBox&lt;number&gt;(10);console.log(box1.get());// 10let box2 =newBox&lt;string&gt;("Hello");console.log(box2.get());// Hello</code></pre>

    On compiling, it will generate the following JavaScript code:

    // generic classclassBox{// constructor with valueconstructor(value){this.val = value;}// Method to get valueget(){returnthis.val;}// Method to set valueset(value){this.val = value;}}// create object of Box classlet box1 =newBox(10);
    console.log(box1.get());// 10let box2 =newBox("Hello");
    console.log(box2.get());// Hello

    Output

    The output of the above code is as follows

    10
    Hello
    

    Example

    In the TypeScript code below:

    • We have defined the 'Stack' class which takes a type parameter 'T'.
    • In the class, we have defined the private variable 'st' whose type is an array of type T.
    • The constructor function initializes the 'st' array.
    • Push() method takes the element of type 'T' as a parameter and inserts it in the 'st' array.
    • The pop() method removes the last element from the 'st' array and returns it.
    • The peek() method returns the last element from the array.
    • The isEmpty() method returns a boolean value based on whether the array is empty.
    • The size() method returns the size of the 'st' array.
    • Next, we have defined the object of the Stack class with the number data type, performed various operations using the methods of the Stack class.
    // Defining the class stackclassStack<T>{// Defining the private array to store the stack elementsprivate st:T[]=[];// Constructor to initialize the stack with initial contentsconstructor(initialContents?:T[]){if(initialContents){this.st = initialContents;}}// Method to push an element to the stackpush(item:T):void{this.st.push(item);}// Method to pop an element from the stackpop():T|undefined{returnthis.st.pop();}// Method to get the top element of the stackpeek():T|undefined{returnthis.st[this.st.length -1];}// Method to check if the stack is emptyisEmpty():boolean{returnthis.st.length ===0;}// Method to get the size of the stacksize():number{returnthis.st.length;}}// Usage Exampleconst numberStack =newStack<number>();
    numberStack.push(1);
    numberStack.push(2);
    numberStack.push(3);console.log(numberStack.peek());// Outputs: 3console.log(numberStack.pop());// Outputs: 3console.log(numberStack.peek());// Outputs: 2console.log(numberStack.isEmpty());// Outputs: falseconsole.log(numberStack.size());// Outputs: 2

    On compiling, it will generate the following JavaScript code:

    // Defining the class stackclassStack{// Constructor to initialize the stack with initial contentsconstructor(initialContents){// Defining the private array to store the stack elementsthis.st =[];if(initialContents){this.st = initialContents;}}// Method to push an element to the stackpush(item){this.st.push(item);}// Method to pop an element from the stackpop(){returnthis.st.pop();}// Method to get the top element of the stackpeek(){returnthis.st[this.st.length -1];}// Method to check if the stack is emptyisEmpty(){returnthis.st.length ===0;}// Method to get the size of the stacksize(){returnthis.st.length;}}// Usage Exampleconst numberStack =newStack();
    numberStack.push(1);
    numberStack.push(2);
    numberStack.push(3);
    console.log(numberStack.peek());// Outputs: 3
    console.log(numberStack.pop());// Outputs: 3
    console.log(numberStack.peek());// Outputs: 2
    console.log(numberStack.isEmpty());// Outputs: false
    console.log(numberStack.size());// Outputs: 2

    Output

    The output of the above code is as follows

    3
    3
    2
    false
    2
    

    Implementing Generic Interface with Generic Classes

    Generic classes can also implement the generic interfaces. So, developers can use a single generic interface to implement multiple generic classes, allowing them to reuse the code.

    Syntax

    You can follow the syntax below to implement the generic interface with generic classes.

    classclass_name<T>implementsinterface_name<T>{// Class body}
    • In the above syntax, 'class class_name<T>' defines the generic class.
    • 'implements' is a keyword to implement the interface with the class.
    • 'interface_name<T>' is a generic interface.

    Example

    In the example below:

    • We have defined the generic interface named 'dataBase', which defines the findById() and save() method.
    • Next, we have defined the generic class named 'memorydataBase', and implemented it with the 'dataBase' interface.
    • In the class, we have defined the 'items' map which stores the numeric value as a key, the value of type 'T'.
    • Next, we have implemented the findById() method, which accesses the value by key from the map and returns it.
    • The save() method stores the key-value pair in the 'items' map.
    • In the end, we created the object of the 'MemorydataBase' class and performed various operations using this method.
    // Defining a generic interfaceinterfacedataBase<T>{findById(id:number):T|undefined;save(item:T):void;}// Defining a class that implements the generic interfaceclassMemorydataBase<T>implementsdataBase<T>{// Defining a private property that is a map of itemsprivate items =newMap<number, T>();// Implementing the findById methodfindById(id:number):T|undefined{returnthis.items.get(id);}// Implementing the save methodsave(item:T):void{const id =this.items.size +1;this.items.set(id, item);}}// Creating an instance of the MemorydataBase classconst repo =newMemorydataBase<string>();
    repo.save("Hello");console.log(repo.findById(1));// Outputs: Hello

    On compiling, it will generate the following JavaScript code:

    // Defining a class that implements the generic interfaceclassMemorydataBase{constructor(){// Defining a private property that is a map of itemsthis.items =newMap();}// Implementing the findById methodfindById(id){returnthis.items.get(id);}// Implementing the save methodsave(item){const id =this.items.size +1;this.items.set(id, item);}}// Creating an instance of the MemorydataBase classconst repo =newMemorydataBase();
    repo.save("Hello");
    console.log(repo.findById(1));// Outputs: Hello

    Output

    The output of the above code is as follows

    Hello

    You may use the 'extends' keyword to use the various constraints with the generic classes. It's always a good idea to use generic parameters, constraints, interfaces, and classes in your code to make it scalable and reusable.

  • Generic Interfaces

    Generic Interfaces

    In TypeScript, the generic interface is similar to the interface but it is defined with one or more type parameters. These type parameters allow the interface to be reused with different data types while still enforcing type safety and consistency.

    An interface in TypeScript is a way to define the structure of the object or class. It specifies the expected format which objects or classes should have, allowing to ensure consistency and type safety in the code.

    Syntax

    You can follow the syntax below to define the generic interfaces.

    interfaceIGeneric<T>{
    
    value1:T;
    value2:T;}</code></pre>

    In the above syntax, 'IGeneric' is a typed interface, which accepts the custom data type. In the interface, we have used the type 'T' as a value of the value1 and value2 properties.

    Example

    In the code below, we have created the 'IGeneric' interface with the custom type 'T'. It has value1 and value2 properties of type 'T'.

    Next, we have defined the 'obj' object of the IGeneric interface type and used the number data type as a generic type with the interface. After that, we print the value of the 'value1' property of the object in the output.

    // Generic interfaceinterfaceIGeneric<T>{
    
    value1:T;
    value2:T;}// Object of generic interfacelet obj: IGeneric&lt;number&gt;={
    value1:10,
    value2:20};console.log(obj.value1);</code></pre>

    On compiling, it will generate the following JavaScript code:

    // Object of generic interfacelet obj ={
    
    value1:10,
    value2:20};
    console.log(obj.value1);

    Output

    The output of the above example code is as follows

    10

    Example

    In this code, we use the 'T' and 'U' both data types with the 'IGeneric' interface. The value1 property is of type 'T' and the value2 property is of type 'U' in the interface.

    Next, we have defined the 'obj' object of interface type with number and string custom data types.

    // Generic interfaceinterfaceIGeneric<T, U>{
    
    value1:T;
    value2:U;}// Define object with a generic interfacelet obj: IGeneric&lt;number,string&gt;={
    value1:10,
    value2:"Hello"};console.log(obj.value2);</code></pre>

    On compiling, it will generate the following JavaScript code:

    // Define object with a generic interfacelet obj ={
    
    value1:10,
    value2:"Hello"};
    console.log(obj.value2);

    Output

    The output of the above example code is as follows

    Hello

    Example

    The below example is very similar to the previous one, but the 'IGeneric' interface contains the merge() method which takes the parameters of type 'U' and returns the value of type 'U'.

    While defining the 'obj' object, we used the number and string data types with the interface. It means the merge() method will take two string parameters, and return a string value.

    In the output, the code prints the concatenated string, which we have passed as parameters of the merge() method.

    // Generic interfaceinterfaceIGeneric<T, U>{
    
    value1:T;// method that returns the value of type Umerge:(a:U, b:U)=&gt;U;}// Define object with a generic interfacelet obj: IGeneric&lt;number,string&gt;={
    value1:10,merge:(a, b)=&gt; a + b
    };console.log(obj.merge("Hello","world!"));// Hello world!

    On compiling, it will generate the following JavaScript code:

    // Define object with a generic interfacelet obj ={
    
    value1:10,merge:(a, b)=&gt; a + b
    }; console.log(obj.merge("Hello","world!"));// Hello world!

    Output

    Helloworld!

    Generic Interface as a Function Type

    Developers can also use the generic interface as a function type. This enables you to use the same function interface across diverse types and scenarios, ensuring type safety without sacrificing flexibility.

    Example

    In the code below, 'func_interface' accepts the generic types T and U. It defines the structure for the function, which takes the parameter of type 'T' and returns the value of type 'U'.

    Next, we have defined the function expression which returns the string length and stored it in the 'stringToLength' variable. The type of the function expression is defined using the generic interface with string and number data types.

    Similarly, we have defined another function that converts a number to a string, and its type is func_interface with string and number data type.

    Next, we invoke the functions and print their output in the console.

    // Define a generic interface for a functioninterfacefunc_interface<T, U>{(input:T):U;}// A specific function that matches the func_interface interfaceconst stringToLength: func_interface<string,number>=(input)=>{return input.length;};// Using the functionconst result =stringToLength("Hello, TypeScript!");// returns 18console.log(result);// Another function that matches the func_interface interfaceconst numberToString: func_interface<number,string>=(input)=>{returnNumber: ${input};};// Using the second functionconst output =numberToString(123);// returns "Number: 123"console.log(output);

    On compiling, it will generate the following JavaScript code:

    // A specific function that matches the func_interface interfaceconststringToLength=(input)=>{return input.length;};// Using the functionconst result =stringToLength("Hello, TypeScript!");// returns 18
    console.log(result);// Another function that matches the func_interface interfaceconstnumberToString=(input)=>{returnNumber: ${input};};// Using the second functionconst output =numberToString(123);// returns "Number: 123"
    console.log(output);

    Output

    18
    Number: 123
    

    In short, generic interfaces allow developers to reuse the interfaces with multiple data types. Generic interfaces can be used as an object or function type, allowing to reuse the single function or object with different structures.

  • Generic Constraints

    In TypeScript, generic constraints allow you to specify limitations on the types that can be used with a generic type parameter. This adds an extra layer of type safety by ensuring the generic code only works with compatible data types.

    Problem Examples

    Before going deep into the generic constraints, let’s understand the problem examples where you need to apply generic constraints.

    Example

    In the code below, we have created the merge() generic function, which takes the two objects as parameters, merges them using the spread operator, and returns the merged object.

    After that, we invoke the merge() function by passing two objects as an argument, and it successfully prints the merged object.

    // Generic function to merge two objectsfunctionmerge<T, U>(obj1:T, obj2:U){return{...obj1,...obj2};}// Invoke the functionconst mergedObj =merge({name:'Sam'},{age:30});console.log(mergedObj);// Output: {name: 'Sam', age: 30}  

    On compiling, it will generate the following JavaScript code.

    // Generic function to merge two objectsfunctionmerge(obj1, obj2){return Object.assign(Object.assign({}, obj1), obj2);}// Invoke the functionconst mergedObj =merge({ name:'Sam'},{ age:30});
    console.log(mergedObj);// Output: {name: 'Sam', age: 30}  

    Output

    The output of the above example code is as follows

    {name: 'Sam', age: 30}
    

    The merge() function has generic parameters. So, it can take the value of any data type as an argument including an object. What if you pass the boolean value as a second argument? Let’s look at the example below.

    Example

    The below example code is very similar to the previous one. We have just changed the second argument of the merge() function to a Boolean value while calling it.

    // Generic function to merge two objectsfunctionmerge<T, U>(obj1:T, obj2:U){return{...obj1,...obj2};}// Invoke the functionconst mergedObj =merge({name:'Sam'},true);console.log(mergedObj);// Output: {name: 'Sam'}  

    On compiling, it will generate the following JavaScript code.

    // Generic function to merge two objectsfunctionmerge(obj1, obj2){return Object.assign(Object.assign({}, obj1), obj2);}// Invoke the functionconst mergedObj =merge({ name:'Sam'},true);
    console.log(mergedObj);// Output: {name: 'Sam'}  

    Output

    The output of the above example code is as follows

    {name: 'Sam'}
    

    The above code prints the first object only in the output because the second argument was a Boolean value but not an object. To solve the problem found in the above example code, developers can use generic constraints.

    How Generic Constraints Work in TypeScript?

    Generic constraints allow us to limit the generic parameters to accept values of only particular types. i.e. you can narrow down the type of the generic parameters.

    Syntax

    You can follow the syntax below to use the generic constraints with generic parameters.

    functionmerge<T extends object>(obj1:T){// Code to execute}
    • In the above syntax, ‘T’ is a generic type, ‘extends’ is a keyword, and ‘object’ is a data type.
    • Here, ‘T’ accepts only values having an ‘object’ data type.

    Let’s understand more about generic constraints via the example below. Now, if you try to compile the below code, you will get the compilation error as the generic parameter can accept only object argument but we are passing Boolean value.

    // Generic function to merge two objectsfunctionmerge<T extends object, U extends object>(obj1:T, obj2:U){return{...obj1,...obj2 };}// Invoke the functionconst mergedObj =merge({ name:'Sam'},true);console.log(mergedObj);

    On compiling the above TypeScript code, the compiler shows the following error

    Argument of type 'boolean' is not assignable to parameter of type 'object'.
    

    This way, we can limit the generic parameters to accept the values of a particular data type.

    Example (Extending Generic Types with Interfaces)

    Let’s understand the code below with a step-by-step explanation.

    • We have defined the ‘Person’ interface which contains name, age, and email properties.
    • Next, we have defined the ‘Employee’ interface which contains ’empCode’, and ’empDept’ properties.
    • The merge() function contains two generic parameters T of type Person and U of type Employee.
    • In the merge() function, we merge both objects.
    • After that, we have defined two objects of type Person, and Employee, respectively.
    • Next, we invoke the merge() function by passing objects as an argument, and the code runs without any error.
    // Define Person interfaceinterfacePerson{
    
    name:string;
    age:number;
    email:string;}// Define Employee interfaceinterface Employee {
    empCode:number;
    empDept:string;}// Generic function which takes Objects of the Person and Employee interfaces typesfunctionmerge&lt;T extends Person, U extends Employee&gt;(obj1:T, obj2:U){return{...obj1,...obj2 };}// Create two objectsconst person: Person ={ name:'John', age:30, email:'[email protected]'};const employee: Employee ={ empCode:1001, empDept:'IT'};// Invoke the functionconst mergedObj =merge(person, employee);console.log(mergedObj);</code></pre>

    On compiling, it will generate the following JavaScript code.

    // Generic function which takes Objects of the Person and Employee interfaces typesfunctionmerge(obj1, obj2){return Object.assign(Object.assign({}, obj1), obj2);}// Create two objectsconst person ={ name:'John', age:30, email:'[email protected]'};const employee ={ empCode:1001, empDept:'IT'};// Invoke the functionconst mergedObj =merge(person, employee);
    console.log(mergedObj);

    Output

    The output of the above example code is as follows

    {
      name: 'John',
      age: 30,
      email: '[email protected]',
      empCode: 1001,
      empDept: 'IT'
    }
    

    Using Type Parameters in Generic Constraints

    TypeScript also allows you to define a type parameter, which is constrained by another parameter of the same function.

    Let's understand it via the example below.

    Example

    In the code below, type 'U' extends the keys of the object received in the first parameter. So, it will accept the keys of the obj object as an argument to avoid errors in the function body.

    Next, we invoke the getValue() function by passing the 'obj' object as an argument. It prints the key value in the output.

    // Parameter U ensures that the key is a valid key of the object T.functiongetValue<T extends object, U extends keyof T>(obj:T, key:U){return obj[key];}// Define an objectconst obj ={
    
    name:'Sam',
    age:34};// Get the value of the key 'name'const name1 =getValue(obj,'name');console.log(name1);// Sam</code></pre>

    On compiling, it will generate the following JavaScript code.

    // Parameter U ensures that the key is a valid key of the object T.functiongetValue(obj, key){return obj[key];}// Define an objectconst obj ={
    
    name:'Sam',
    age:34};// Get the value of the key 'name'const name1 =getValue(obj,'name');
    console.log(name1);// Sam

    Output

    The output of the above example code is as follows

    Sam
    

    We understood that generic constraints are useful to accept the values of the specific data types as a parameter, instead of taking values of all data types.

  • Generics

    Generics are a powerful feature in TypeScript that allow you to write reusable code that can work with different types of data. They act like placeholders that can be filled with specific data types when you use the generic code. This improves code flexibility and maintainability.

    Problem Examples

    Before going deep into TypeScript generics, let’s understand the problem examples where you need to apply generics.

    Let’s start with the example below, where you want to log the value of the variable passed as a parameter.

    Example

    In the code below, we have defined the printVar() function which takes the number value as a parameter and logs the value in the console. Next, we invoke the function by passing 10 as an argument.

    functionprintVar(val:number){console.log(val);// Prints the value of val}printVar(10);// Invokes the function with a number

    On compiling, it will generate the following JavaScript code.

    functionprintVar(val){
    
    console.log(val);// Prints the value of val}printVar(10);// Invokes the function with a number</code></pre>

    Output

    Its output is as follows

    10
    

    Now, let's suppose you want to extend the use case of the printVar() function to print the value of other types of variables like string, boolean, etc. One way of doing that is as shown in the example below.

    Example

    In the code below, the printVar() function can accept the arguments of number, string, or boolean type.

    functionprintVar(val:number|string|boolean){console.log(val);// Prints the value of val}printVar(true);// Invokes the function with a boolean value

    On compiling, it will generate the following JavaScript code.

    functionprintVar(val){
    
    console.log(val);// Prints the value of val}printVar(true);// Invokes the function with a boolean value</code></pre>

    Output

    The output is as follows

    true
    

    What if you want to print the array or object value? You need to extend the types of the 'val' parameter, and it makes the code complex to read.

    Another way to use the parameters of 'any' data type is as shown in the example below.

    Example

    In the code below, the type of the 'val' parameter is any. So, it can accept any type of value as an argument.

    functionprintVar(val:any){console.log(val);// Prints the value of val}printVar("Hello world!");// Invokes the function with a boolean value

    On compiling, it will generate the following JavaScript code

    functionprintVar(val){
    
    console.log(val);// Prints the value of val}printVar("Hello world!");// Invokes the function with a boolean value</code></pre>

    Output

    Its output is as follows

    Hello world!
    

    The problem with the above code is that you won't have a reference to the data type inside the function. Whether you pass a string, number, boolean, array, etc. as a function argument, you will get the 'any' type of the variable in the function.

    Here, generic functions come into the picture.

    TypeScript Generics

    In TypeScript, generics is a concept that allows to creation of reusable components like functions, classes, interfaces, etc. It creates a function, classes, etc. which can work with multiple data types instead of the single data type. In short, it allows developers to create programs that can work with multiple data types and are scalable in the long term.

    Syntax

    Users can follow the syntax below to use the generic variables with functions in TypeScript.

    functionprintVar<T>(val:T){// execute the code}printVar(val);
    • Developers can use the type variable in the angular bracket(<>) after the function name.
    • After that, you can use the type variable T as a type of the parameters.
    • Here, developers can use any valid identifier instead of 'T'.
    • After that, you can call the function with the value of any data type, and the function automatically captures the data type of the variable.

    Example

    In the example below, the printVar() function is a generic function, which takes the value of any data type as an argument, and prints it.

    After that, we have invoked the function with array, object, and boolean value. In the output, users can observe that it prints the value of different types of variables without any error.

    functionprintVar<T>(val:T){// T is a generic typeconsole.log("data: ", val);}let arr =[1,2,3];let obj ={ name:"John", age:25};printVar(arr);// Val is arrayprintVar(obj);// Val is ObjectprintVar(true);// Val is boolean

    On compiling, it will generate the following JavaScript code.

    functionprintVar(val){
    
    console.log("data: ", val);}let arr =&#91;1,2,3];let obj ={ name:"John", age:25};printVar(arr);// Val is arrayprintVar(obj);// Val is ObjectprintVar(true);// Val is boolean</code></pre>

    Output

    The output of the above example code is as follows

    data:  [ 1, 2, 3 ]
    data:  { name: 'John', age: 25 }
    data:  true
    

    Example

    In this code, we printVar() function is a generic function, which takes the type of the variable value passed as a parameter. While invoking the function, we have passed the value of different data types, and users can observe the type of each variable in the output.

    functionprintVar<T>(val:T){// T is a generic typeconsole.log("data: ",typeof val);}printVar(2);// Val is numberprintVar("Hello");// Val is stringprintVar(true);// Val is boolean

    On compiling, it will generate the following JavaScript code.

    functionprintVar(val){
    
    console.log("data: ",typeof val);}printVar(2);// Val is numberprintVar("Hello");// Val is stringprintVar(true);// Val is boolean</code></pre>

    Output

    The output of the above example code is as follows

    data:  number
    data:  string
    data:  boolean
    

    Example

    In the code below, the concatenate() function takes two parameters of type T and U, respectively. It uses the spread operator to concatenate the value of the 'first' and 'second' parameters.

    Next, we call the function to concatenate two strings and arrays. In the output, we can observe that the concatenate() function executes without any error and prints the final output in the console.

    functionconcatenate<T, U>(first:T, second:U):T&U{return{...first,...second};}// Example usage with stringsconst resultString =concatenate("Hello, ","world!");console.log(resultString);// Output: Hello, world!// Example usage with arraysconst resultArray =concatenate([1,2,3],[4,5,6]);console.log(resultArray);// Output: [1, 2, 3, 4, 5, 6]

    On compiling, it will generate the following JavaScript code.

    functionconcatenate(first, second){return Object.assign(Object.assign({}, first), second);}// Example usage with stringsconst resultString =concatenate("Hello, ","world!");
    console.log(resultString);// Output: Hello, world!// Example usage with arraysconst resultArray =concatenate([1,2,3],[4,5,6]);
    console.log(resultArray);// Output: [1, 2, 3, 4, 5, 6]

    Output

    The output of the above example code is as follows

    {
      '0': 'w',
      '1': 'o',
      '2': 'r',
      '3': 'l',
      '4': 'd',
      '5': '!',
      '6': ' '
    }
    { '0': 4, '1': 5, '2': 6 }
    

    Benefits of Generics

    Here are some benefits of using generics in TypeScript.

    • Type Safety: Generics enforce type consistency, reducing runtime errors by catching mistakes at compile time.
    • Code Reusability: Developers can define a single generic function, class, or interface that works with different data types. It reduces the code duplication.
    • Improved Readability: By using Generics, developers can write cleaner and easy-to-read code.
    • Enhanced Performance: You can increase the performance of the application by avoiding unnecessary type casting and checks via using generics.