Generics in TypeScript are a fundamental feature that enhances the language's flexibility and reusability by introducing type parameters. With generics, developers can write more versatile and type-safe functions, classes, and interfaces that can adapt to a wide range of data types. This article serves as a comprehensive guide to understanding generics, exploring their benefits, and leveraging them to write cleaner and more maintainable TypeScript code.

Understanding Generics:

At its core, generics provide a way to create components (functions, classes, and interfaces) that can work with various data types while maintaining type safety. They serve as placeholders for data types, allowing us to define generic algorithms and structures without committing to a specific type beforehand.

Generics are denoted by angle brackets < >, and we use placeholders (type parameters) to represent the data types. These type parameters can be named anything, but it's a convention to use single uppercase letters such as T, U, K, etc.

Simple Generic Function:

Let's start with a simple example of a generic function that echoes the input it receives.

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

// Usage
const result1 = echo("Hello, TypeScript!"); // Output: "Hello, TypeScript!"
const result2 = echo(42); // Output: 42
const result3 = echo(true); // Output: true

In this example, the echo function takes a generic type T as an argument and returns the same type. The type of the returned value will be the same as the type of the argument passed in.

Generic Array:

Generics are especially useful when dealing with collections like arrays. Let's create a generic function to print the elements of an array.

function printArray<T extends string>(arr: T[]): void {
  arr.forEach(item => console.log(item));
}

// Usage
const fruits = ["apple", "banana", "orange"];
const numbers = [1, 2, 3, 4, 5];

printArray(fruits); // Output: apple banana orange
printArray(numbers); //TYPE ERROR : Type 'number' is not assignable to type 'string'.

Here, we use the generic type T in the function signature to denote that the function can work with only string array.

Generic Interface:

Generics can also be applied to interfaces, enabling us to create flexible and reusable type definitions.

interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

// Usage
const pair1: KeyValuePair<string, number> = { key: "age", value: 30 };
const pair2: KeyValuePair<string, boolean> = { key: "isStudent", value: true };

In this example, we define a generic interface KeyValuePair with two type parameters K and V, representing the types of the key and value respectively. This allows us to create instances of the interface with different types for the key and value.

Constraints on Generics:

We can impose constraints on the types that can be used with generics by specifying the allowable types.

interface Lengthy {
  length: number;
}

function logLength<T extends Lengthy>(arg: T): void {
  console.log(arg.length);
}

// Usage
logLength("Hello"); // Output: 5
logLength([1, 2, 3]); // Output: 3
logLength(42); // Error: 'number' does not have a 'length' property

In this example, the generic function logLength has a constraint extends Lengthy, meaning that the type T must have a length property. This way, we can ensure that the function works only with types that have a valid length property.