Generic functions

7 minutes read

Generic functions in TypeScript are versatile tools, enabling you to write flexible and reusable code. Different from regular functions, generic functions handle a variety of data types while maintaining type safety. Consider them as superheroes within the TypeScript world, adaptable to varying situations.

Definition, purpose, and other perks

Consider this scenario: you have a function that works with strings. It logs an array of strings to the console:

function printStrings(arr: string[]): void {
  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
  }
}

Then, you realize you need another function with the same functionality, but it should work with an array of numbers:

function printNumbers(arr: number[]): void {
  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
  }
}

As you can see, the function remains the same, with the exception of the argument's type. This is where the concept of generic functions comes in. They're functions that can handle a variety of data types. Generic functions achieve this adaptability through the use of type parameters. These parameters act as stand-ins for the actual types the function will work with. The primary goal of generic functions is to provide code that is adaptable yet safe. Let's see what this function and its caller functions might look like:

function printArray<T>(arr: T[]): void {       // Generic function with a type parameter T
  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
  }
}

 printArray<string>(['Hello,', 'generics!']);  // Caller functions
 printArray<number>([1,2,3]);

Let's dig into its main advantages. So, generic functions offer:

  • Flexibility: Generic functions can handle different data types, making them versatile in numerous scenarios.

  • Type Safety: Despite their flexibility, generic functions uphold type safety by allowing you to specify the data types they work with.

  • Code Reusability: Write a function once and use it with different data types, reducing repetition in your code.

How to declare a generic function

In TypeScript, you can declare a generic function by including a type parameter within angle brackets <T>. Here's an example:

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

In this case, T serves as a type parameter, acting as a stand-in for the actual type. The function accepts an argument of type T and returns an entity of the same type. When you need to call the function, simply replace the type parameter T with the specific type you're working with:

let result = echo<string>("I really like these generics!");

Test cases

Let's explore some examples to understand how we can apply generic functions in diverse scenarios.

For the reverseArray function, the generic type T allows the reversing of arrays of any type:

function reverseArray<T>(arr: T[]): T[] {
  return arr.reverse();
}

console.log(reverseArray<number>([1, 2, 3]));
// Output: [ 3, 2, 1 ]

console.log(reverseArray<string>(["h", "e", "l", "l", "o"]));  
// Output: [ 'o', 'l', 'l', 'e', 'h' ]

This filterArray function is designed to filter an array with elements of any type. It uses a predicate function, which you introduce as a second parameter:

function filterArray<T>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(item => predicate(item));
}

const filteredNum = filterArray<number>([3, 67, 2], (item) => item < 3);
console.log(filteredNum);   // Output: [ 2 ]

const filteredStr = filterArray<string>(["type", "script", "generic"], (item) => item.length > 6);
console.log(filteredStr);   // Output: [ 'generic' ]

The swap function alters the position of chosen elements in the array and then hands back the revised array:

function swap<T>(arr: T[], index1: number, index2: number): T[] {
  [arr[index1], arr[index2]] = [arr[index2], arr[index1]];
  return arr;
}
const arr = ['0', '1', '2', '3'];
console.log(swap<string>(arr, 0, 2));    // Output: [ '2', '0', '1', '3' ]

Working with multiple type parameters

You can create generic functions that use more than one type parameter. When doing this, you can assign different types to each parameter. This allows for a wide range of possibilities. Here's the basic syntax along with some examples:

function pair<T, U>(first: T, second: U): [T, U] {   
  return [first, second];
}

let result = pair<string, number>("Hello", 11);  // Output: [ 'Hello', 11 ]

Having multiple type parameters lets you construct functions that can handle different types independently. For example, a function that pairs two elements into a tuple.

Here's another example of a function that uses several type parameters:

function compose<T, U>(f: (arg: T) => T,    // First (f) function's argument T returns T type
                       g: (arg: T) => U,    // Second (g) function's argument T returns U type
                       arg: T)              // Initial value (arg) of type T
                     : U {                  // U is a return type
  return g(f(arg))
}

Let's dissect this example. The compose function receives two functions (f and g) and a value (arg), where:

  • f is a function that accepts a T type argument and returns a T type value.

  • g is a function that takes a T type argument and returns a U type value.

  • arg is the initial value of the T type. This will be passed to f.

The type parameter T denotes the initial argument type, U stands for the return type of the first function's execution, and V is the resulting type. This generic function enables to chain operations together. By combining smaller, task-specific functions f and g, you form reusable and modular code. Each function is designed to accomplish a particular task, and combining them allows for various task combinations.

Adding Constraints to Type Parameters

Sometimes, you might want to limit the types that can be used with a generic function. You can do this by adding constraints with the extends keyword:

function numberEcho<T extends number[]>(arg: T): T {
  return arg;
}

In this example, numberEcho<T extends number> specifies that T must be a number or a subtype of the number. If you try to instantiate it with a string, like below, it will produce a TypeScript error.

const strEcho = new numberEcho("Hello!"); // Error: Argument of type 'string' is not
                                          // assignable to parameter of type 'number[]'

Alternatively, you may want to accept only types that have a specific property as a parameter:

function printLength<T extends { length: number }>(input: T): void {
  console.log(`Length of the input: ${input.length}`);
}

printLength<string>('Constraints are useful!'); // Output: Length of the input: 23
printLength<number[]>([1,2,4]);                 // Output: Length of the input: 3
printLength<number>(124);                       // Error

Here, the function printLength() accepts only types that have a length property. Both string and number[] satisfy this condition, while the type number does not meet the constraint of { length: number }.

Why use generic functions?

Generic functions in TypeScript provide straightforward and reusable solutions when working with different data types. These functions ensure type safety, support code abstraction, and boost flexibility. Generics aid in protecting your code from duplication, increase readability, and offer scalability, resulting in cleaner and more maintainable code.

Type safety:

  • Generics serve as a tool to uphold and maintain type safety during the crafting of flexible functions.

  • They allow you to handle various types without giving up static type checking.

Abstraction and flexibility:

  • Generic functions give you the possibility to develop abstract algorithms viable with a broad range of data types.

  • They afford a high degree of flexibility by enabling you to indicate types once the function gets called.

Better code organization:

  • Generic functions help streamline your code organization by grouping similar logic into one function.

  • This consolidation may lead to a cleaner and more modular code structure.

Improved scalability and readability:

  • As your codebase grows, generic functions can adjust to accommodate new types without extensive modifications.

  • Generic functions can enhance code readability by encapsulating logic unaffected by specific types.

In conclusion, the motivation to use generic functions stems from the need for flexible, reusable, and type-safe code. They become especially useful when you aim to create adaptable functions compatible with a variety of data types while keeping a high level of readability and maintainability.

Conclusion

Generic functions are very important for improving type safety, minimizing repetition, and fostering good coding habits. As you progress in your TypeScript learning, strive to learn more about generics. Try them in various situations, solve tough problems, and use generic functions in your projects. This will improve the quality and upkeep of your code.

How did you like the theory?
Report a typo