TypeScript Generics: Building Flexible and Reusable Components

November 2, 2024 (2w ago)

TypeScript Generics: Building Flexible and Reusable Components

TypeScript generics enable you to create flexible, reusable, and type-safe components that work across various data types. Generics allow you to define placeholders for types, making it possible to write code that can handle different types while maintaining strict type safety. In this guide, we’ll explore how to use generics in functions, classes, and interfaces, along with best practices and practical examples to make your TypeScript code more versatile.


What Are Generics?

Generics in TypeScript are a way to define type variables that can be used across functions, classes, and interfaces. These type variables (commonly represented by <T>) allow you to create components that work with any type, without sacrificing type safety.

Why Use Generics?

  1. Type Safety: Generics maintain strict typing while allowing flexible types.
  2. Code Reusability: Generic components can be used with various data types, reducing redundancy.
  3. Better Code Readability: Generics make it clear that a function or component is intended to work with multiple types.

Using Generics in Functions

Generics are commonly used in functions to define flexible parameter and return types.

Basic Generic Function

Here’s an example of a simple generic function that accepts a value and returns it as the same type.

function identity<T>(value: T): T {
  return value;
}
 
console.log(identity<string>("Hello"));  // Output: "Hello"
console.log(identity<number>(42));       // Output: 42

In this example, identity uses a generic type T, which allows it to handle any type. When calling the function, you can specify the type <string> or <number>, or let TypeScript infer it automatically.

Inferring Generic Types

TypeScript can often infer the generic type based on the function arguments, so you don’t need to specify it explicitly.

const result = identity("Hello"); // TypeScript infers that T is 'string'
console.log(result); // Output: "Hello"

Constraints in Generics

Sometimes, you want to restrict the types that a generic can accept. Constraints allow you to limit a generic type to those that extend a specific type.

Using Constraints with extends

function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}
 
console.log(getLength("TypeScript")); // Output: 10
console.log(getLength([1, 2, 3]));    // Output: 3
// console.log(getLength(42));         // Error: Argument of type 'number' is not assignable

In this example, T is constrained to types with a length property (like string or array). This allows getLength to safely access length without errors.

Multiple Constraints

You can apply multiple constraints to a generic type by using intersection types.

interface Nameable {
  name: string;
}
 
interface Ageable {
  age: number;
}
 
function displayPerson<T extends Nameable & Ageable>(person: T) {
  console.log(`Name: ${person.name}, Age: ${person.age}`);
}
 
const person = { name: "Alice", age: 30, city: "New York" };
displayPerson(person); // Output: "Name: Alice, Age: 30"

Here, T is constrained to types that have both name and age properties, making displayPerson more flexible while maintaining strict type safety.


Generics in Interfaces

Generics are highly useful in interfaces, allowing you to define reusable structures for complex data.

Defining a Generic Interface

interface Box<T> {
  contents: T;
}
 
const stringBox: Box<string> = { contents: "TypeScript" };
const numberBox: Box<number> = { contents: 100 };
 
console.log(stringBox.contents); // Output: "TypeScript"
console.log(numberBox.contents); // Output: 100

Here, the Box<T> interface defines a container for any type, making it a reusable and flexible data structure.

Generic Interface with Multiple Types

You can use multiple type variables in an interface to handle more complex data relationships.

interface Result<T, U> {
  success: T;
  error: U;
}
 
const successResult: Result<boolean, string> = { success: true, error: "" };
const errorResult: Result<boolean, string> = { success: false, error: "Error occurred" };
 
console.log(successResult);
console.log(errorResult);

In this example, Result<T, U> allows you to define both a success and an error type, making it versatile for handling different types of responses.


Generics in Classes

Generics are also powerful in classes, enabling you to build reusable components with strict type constraints.

Basic Generic Class

class DataStorage<T> {
  private data: T[] = [];
 
  add(item: T) {
    this.data.push(item);
  }
 
  remove(item: T) {
    this.data = this.data.filter((i) => i !== item);
  }
 
  getData(): T[] {
    return this.data;
  }
}
 
const textStorage = new DataStorage<string>();
textStorage.add("TypeScript");
textStorage.add("JavaScript");
textStorage.remove("JavaScript");
console.log(textStorage.getData()); // Output: ["TypeScript"]
 
const numberStorage = new DataStorage<number>();
numberStorage.add(10);
numberStorage.add(20);
console.log(numberStorage.getData()); // Output: [10, 20]

In this example, DataStorage<T> can store any type of data (string or number), making it a versatile class for managing collections.

Adding Constraints to Generic Classes

Constraints can be added to generic classes to ensure that only certain types are used.

class ScoreBoard<T extends number | string> {
  private scores: T[] = [];
 
  addScore(score: T) {
    this.scores.push(score);
  }
 
  getScores() {
    return this.scores;
  }
}
 
const scoreBoard = new ScoreBoard<number>();
scoreBoard.addScore(100);
scoreBoard.addScore(200);
console.log(scoreBoard.getScores()); // Output: [100, 200]

The ScoreBoard<T> class is constrained to only accept number or string types, ensuring that scores are limited to meaningful types.


Generic Utility Types

TypeScript provides several generic utility types that make it easy to transform and work with complex types. Here are a few commonly used generic utilities:

1. Array<T>

The Array<T> generic type is used to define arrays with specific element types.

const stringArray: Array<string> = ["TypeScript", "JavaScript"];
const numberArray: Array<number> = [1, 2, 3];

2. Promise<T>

The Promise<T> generic type represents a promise that resolves to a specific type.

function fetchData(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Data loaded"), 1000);
  });
}
 
fetchData().then((data) => console.log(data)); // Output after 1 second: "Data loaded"

Here, fetchData returns a promise that resolves to a string, ensuring that the resolved value is always a string.


Practical Applications of Generics

1. Creating a Generic API Response Type

Generics are excellent for handling dynamic data, such as API responses with variable data types.

interface ApiResponse<T> {
  status: number;
  data: T;
}
 
function fetchApiResponse<T>(url: string): Promise<ApiResponse<T>> {
  return fetch(url)
    .then((response) => response.json())
    .then((data) => ({ status: response.status, data }));
}
 
fetchApiResponse<User[]>("/api/users").then((response) => {
  console.log(response.data); // Type-safe access to User array
});

Using ApiResponse<T> ensures that data is of type T, allowing type-safe handling of different API endpoints.

2. Generic Utility Function for Array Filtering

A generic function can be used to filter arrays based on a property or condition, regardless of the array type.

function filterByProperty<T, K extends keyof T>(items: T[], key: K, value: T[K]): T[] {
  return items.filter((item) => item[key] === value);
}
 
interface Product {
  id: number;
  name: string;
  category: string;
}
 
const products: Product[] = [
  { id: 1, name: "Laptop", category: "Electronics" },
  { id: 2, name: "Shoes", category: "Apparel" },
  { id: 3, name: "Phone", category: "Electronics" },
];
 
const electronics = filterByProperty(products, "category", "Electronics");
console.log(electronics); // Output: [{ id: 1, name: "Laptop", ... }, { id: 3, name: "Phone", ... }]

The filterByProperty

function can work with any type of array, making it highly reusable for filtering data based on dynamic criteria.


Conclusion

TypeScript generics are a powerful tool for creating flexible, reusable, and type-safe code. By using generics in functions, interfaces, and classes, you can build components that handle various data types while preserving strict typing. With constraints and practical applications like API responses and utility functions, generics can significantly enhance your TypeScript skills, making your code more versatile and maintainable.

Incorporate generics into your TypeScript projects to unlock the full potential of type-safe and reusable components.