Skip to content

TypeScript Generics: Building Flexible and Reusable Components

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.

Generics Concept Overview

graph TB
    subgraph "Without Generics"
        NON_GEN1[function identityString(value: string): string]
        NON_GEN2[function identityNumber(value: number): number]
        NON_GEN3[function identityBoolean(value: boolean): boolean]
        NON_PROB[❌ Code Duplication<br/>❌ Not Scalable<br/>❌ Maintenance Issues]
    end
    
    subgraph "With Generics"
        GEN_FUNC[function identity&lt;T&gt;(value: T): T]
        GEN_TYPES[T can be: string | number | boolean | object | custom types]
        GEN_BENEFITS[✅ Single Function<br/>✅ Type Safe<br/>✅ Reusable<br/>✅ Scalable]
    end
    
    subgraph "Generic Type Flow"
        INPUT[Input: identity&lt;string&gt;("hello")]
        SUBSTITUTE[T = string]
        OUTPUT[Output: function(value: string): string]
        RESULT[Returns: "hello" with string type]
    end
    
    NON_GEN1 --> NON_PROB
    NON_GEN2 --> NON_PROB
    NON_GEN3 --> NON_PROB
    
    GEN_FUNC --> GEN_TYPES
    GEN_TYPES --> GEN_BENEFITS
    
    INPUT --> SUBSTITUTE
    SUBSTITUTE --> OUTPUT
    OUTPUT --> RESULT
    
    style NON_PROB fill:#ffebee
    style GEN_BENEFITS fill:#e8f5e8
    style GEN_FUNC fill:#e1f5fe

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.

// @filename: index.ts
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

// @filename: index.ts
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.

Generic Constraints Visualization

graph TB
    subgraph "Unconstrained Generic"
        UNCONSTRAINED[function process&lt;T&gt;(value: T): T]
        ANY_TYPE[T can be ANY type<br/>string, number, boolean, object, etc.]
        NO_SAFETY[❌ No guarantee about properties<br/>❌ Cannot safely access methods]
    end
    
    subgraph "Constrained Generic"
        CONSTRAINED[function getLength&lt;T extends {length: number}&gt;(item: T): number]
        LIMITED_TYPES[T must have 'length' property<br/>✅ string, Array, etc.<br/>❌ number, boolean]
        TYPE_SAFETY[✅ Safe to access .length<br/>✅ Compile-time validation<br/>✅ Better IntelliSense]
    end
    
    subgraph "Constraint Examples"
        EX1[T extends string → Only string types]
        EX2[T extends keyof User → Only User property keys]
        EX3[T extends Function → Only function types]
        EX4[T extends Record&lt;string, any&gt; → Only object types]
    end
    
    UNCONSTRAINED --> ANY_TYPE
    ANY_TYPE --> NO_SAFETY
    
    CONSTRAINED --> LIMITED_TYPES
    LIMITED_TYPES --> TYPE_SAFETY
    
    TYPE_SAFETY --> EX1
    TYPE_SAFETY --> EX2
    TYPE_SAFETY --> EX3
    TYPE_SAFETY --> EX4
    
    style NO_SAFETY fill:#ffebee
    style TYPE_SAFETY fill:#e8f5e8
    style CONSTRAINED fill:#e1f5fe

Multiple Constraints

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

// @filename: index.ts
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

// @filename: index.ts
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.

// @filename: index.ts
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

// @filename: index.ts
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.

// @filename: index.ts
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.

// @filename: index.ts
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.

// @filename: index.ts
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.

// @filename: index.ts
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.

TypeScript JavaScript Type Safety Best Practices
Share:

Continue Reading

TypeScript Design Patterns: Modern Implementation Guide

A comprehensive guide to implementing design patterns in TypeScript, covering creational, structural, and behavioral patterns with practical examples and best practices for modern web development.

Read article
TypeScriptJavaScriptType Safety

TypeScript Utility Types: Simplifying Code with Mapped Types

TypeScript offers a suite of utility types designed to simplify complex type transformations, allowing developers to create new types based on existing ones with minimal effort. Utility types make code more readable, maintainable, and type-safe by reducing repetition and eliminating boilerplate. In this guide, we’ll explore the most commonly used TypeScript utility types like Partial, Pick, Omit, and others, and discuss practical scenarios for applying each.

Read article
TypeScriptJavaScriptType Safety

TypeScript Interfaces vs. Types: Choosing the Right Tool for Your Code

When working with TypeScript, defining data structures, function signatures, and other contracts is key to creating robust, type-safe applications. Interfaces and types are two ways to define these structures, but each has unique strengths and use cases. This guide will help you understand the differences between interfaces and types, when to use each, and how they can improve code readability and maintainability.

Read article
TypeScriptJavaScriptType Safety

AI-Assisted Content

This article includes AI-assisted content that has been reviewed for accuracy. Always test code snippets before use.