What are generics in typescript - why use them, how do they work with code examples

Arnab Chatterjee - Aug 29 - - Dev Community

Introduction

What Are Generics?

Generics in TypeScript provide a way to create components that can work with a variety of types rather than a single one. They allow you to define functions, classes, or interfaces that are flexible and reusable for different data types while maintaining strong type safety.

In essence, generics enable you to write code that can adapt to different types without losing the benefits of TypeScript’s type system. This flexibility helps in building robust and maintainable code that can handle a wide range of scenarios.

Why Use Generics?

  1. Code Reusability:

Generics allow you to write functions, classes, or interfaces that can operate on multiple types without duplicating code. Instead of creating separate versions of a function for each type, you can use a single generic version that works with any type.

    function identity<T>(arg: T): T {
    return arg;
    }
    console.log(identity<string>("Hello")); // Works with strings
    console.log(identity<number>(42)); // Works with numbers
Enter fullscreen mode Exit fullscreen mode

In this example, the identity function is generic. It can accept and return any type T, making it reusable for different data types.

  1. Type Safety

Generics ensure that your code maintains type safety while offering flexibility. When you use generics, TypeScript checks that the types passed to the generic components are consistent, reducing the likelihood of runtime errors.

   function wrapInArray<T>(value: T): T[] {
    return [value];
    }

    const stringArray = wrapInArray("Hello"); // TypeScript knows this is a string array
    const numberArray = wrapInArray(42); // TypeScript knows this is a number array
Enter fullscreen mode Exit fullscreen mode

Here, wrapInArray returns an array containing the provided value, and TypeScript ensures that the array’s type is consistent with the value type.

  1. Avoiding Redundancy

Without generics, you might end up writing multiple versions of the same function or class to handle different types. Generics eliminate this redundancy, leading to cleaner, more maintainable code.

Example Without Generics:

   function logString(value: string): void {
    console.log(value);
    }

    function logNumber(value: number): void {
    console.log(value);
    }
Enter fullscreen mode Exit fullscreen mode

Example With Generics:

   function logValue<T>(value: T): void {
    console.log(value);
    }

    logValue("Hello"); // Works with strings
    logValue(42); // Works with numbers

Enter fullscreen mode Exit fullscreen mode

With generics, the logValue function can handle any type, reducing the need to write separate functions for each type.

How Generics Work in TypeScript

Basic Syntax of Generics

Generics in TypeScript use a placeholder syntax, often represented by , where T stands for "type." This allows you to define a function, class, or interface that can operate on various data types without losing type safety.

function identity<T>(value: T): T {
    return value;
}

const stringIdentity = identity("Hello World");
const numberIdentity = identity(42);

Enter fullscreen mode Exit fullscreen mode

Generic Functions

Generic functions are functions that can work with multiple types without duplicating code.

function wrapInArray<T>(value: T): T[] {
    return [value];
}

const stringArray = wrapInArray("Hello");
const numberArray = wrapInArray(123);

Enter fullscreen mode Exit fullscreen mode

In this example, the wrapInArray function wraps any value in an array. TypeScript infers the type when the function is called, ensuring the array contains the correct type.

Generic Interfaces

Generic interfaces allow you to define a contract that can apply to different types.

Example:

interface Pair<T, U> {
    first: T;
    second: U;
}

const nameAgePair: Pair<string, number> = {
    first: "Alice",
    second: 30,
};
Enter fullscreen mode Exit fullscreen mode

Generic Classes

Generic classes are useful for creating data structures that can store or manage data of any type.
Example:

class Box<T> {
    contents: T;

    constructor(value: T) {
        this.contents = value;
    }

    getContents(): T {
        return this.contents;
    }
}

const stringBox = new Box("Gift");
const numberBox = new Box(100);

Enter fullscreen mode Exit fullscreen mode

Explanation: In this Box class, the type T allows the class to store and retrieve any type of data. This approach is similar to how you might use a storage container in real life, where the container's shape (type) can vary based on its contents.

Generic Constraints

Sometimes, you want to restrict the types that a generic can accept. This is where generic constraints come in.

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}

const car = { make: "Toyota", year: 2022 };
const make = getProperty(car, "make"); // Valid
const year = getProperty(car, "year"); // Valid

Enter fullscreen mode Exit fullscreen mode

In this example, the getProperty function ensures that the key you pass must be a valid property of the object. This constraint helps prevent errors by enforcing that the key exists on the given object.

Common Use Cases of Generics

Working with Arrays and Collections

Generics are particularly useful when working with arrays or collections of data, where the type of elements may vary.

function mergeArrays<T>(arr1: T[], arr2: T[]): T[] {
    return arr1.concat(arr2);
}

const numbers = mergeArrays([1, 2, 3], [4, 5, 6]);
const strings = mergeArrays(["a", "b", "c"], ["d", "e", "f"]);

Enter fullscreen mode Exit fullscreen mode

In this example, the mergeArrays function uses a generic type T to merge two arrays of any type. Whether the arrays contain numbers, strings, or any other type, the function handles them seamlessly.

Think of this like combining two boxes of items (e.g., fruits or tools). The type of item inside the boxes can vary, but the process of combining them remains the same.

API Response Handling

When dealing with API responses, the data structure returned by different endpoints might vary. Generics can simplify handling these responses by creating flexible functions that work with various types.

interface ApiResponse<T> {
    status: number;
    data: T;
    message?: string;
}

function handleApiResponse<T>(response: ApiResponse<T>): T {
    if (response.status === 200) {
        return response.data;
    } else {
        throw new Error(response.message || "API Error");
    }
}

const userResponse = handleApiResponse({
    status: 200,
    data: { name: "John", age: 30 },
});

const productResponse = handleApiResponse({
    status: 200,
    data: { id: 1, name: "Laptop" },
});

Enter fullscreen mode Exit fullscreen mode

In this example, the handleApiResponse function works with any type of API response, whether it's user data, product details, or something else. The generic type T ensures that the function returns data of the correct type based on the response.

Imagine receiving different packages (API responses) at your door. The contents might vary (e.g., groceries, electronics), but you have a method to unpack each one correctly based on what's inside.

Utility Types

TypeScript provides several utility types that use generics under the hood to perform common type transformations. These utility types are incredibly useful for shaping and controlling the types in your code.

Example:

  1. Partial<T>: Makes all properties in T optional.
interface User {
    name: string;
    age: number;
    email: string;
}

const updateUser: Partial<User> = {
    email: "newemail@example.com",
};
Enter fullscreen mode Exit fullscreen mode
  1. ReadOnly<T>: Makes all properties in T read-only.
const user: Readonly<User> = {
    name: "John",
    age: 30,
    email: "john@example.com",
};

// user.age = 31; // Error: Cannot assign to 'age' because it is a read-only property.

Enter fullscreen mode Exit fullscreen mode
  1. Record<K, T>: Constructs an object type whose property keys are K and whose property values are T.
   const rolePermissions: Record<string, string[]> = {
    admin: ["create", "edit", "delete"],
    user: ["view", "comment"],
};

Enter fullscreen mode Exit fullscreen mode

These utility types (Partial, ReadOnly, Record) are built using generics to provide flexible and reusable transformations on types. They help in scenarios like updating parts of an object, ensuring immutability, or creating dictionaries/maps in TypeScript.

Advanced Topics in Generics

Multiple Type Parameters

TypeScript allows the use of multiple type parameters in a function or class, enabling even greater flexibility and control over how different types interact within your code.

function mergeObjects<T, U>(obj1: T, obj2: U): T & U {
    return { ...obj1, ...obj2 };
}

const person = { name: "Alice" };
const contact = { email: "alice@example.com" };

const merged = mergeObjects(person, contact);
// merged is of type { name: string } & { email: string }

Enter fullscreen mode Exit fullscreen mode

In this example, the mergeObjects function uses two generic type parameters, T and U, to merge two objects. The resulting object combines the properties of both input objects, with TypeScript ensuring the correct types are inferred for the merged result

Default Generic Types

TypeScript also allows you to define default types for generic parameters. This feature provides a fallback type if none is explicitly provided, simplifying the usage of generics in your code.

function createPair<T = string>(value1: T, value2: T): [T, T] {
    return [value1, value2];
}

const stringPair = createPair("Hello", "World"); // Defaults to [string, string]
const numberPair = createPair<number>(10, 20); // Explicitly set to [number, number]

Enter fullscreen mode Exit fullscreen mode

The createPair function has a default type of string for its generic type T. If no type is specified, TypeScript will use string, but you can override this by providing a different type when necessary.

Type Inference with Generics

TypeScript is capable of inferring the types of generics based on how a function or class is used. This reduces the need to explicitly specify types, making the code more concise and easier to work with.

function wrapInArray<T>(value: T): T[] {
    return [value];
}

const numberArray = wrapInArray(42); // TypeScript infers T as number
const stringArray = wrapInArray("Hello"); // TypeScript infers T as string

Enter fullscreen mode Exit fullscreen mode

Explanation: In the wrapInArray function, TypeScript automatically infers the type of T based on the type of the argument passed to the function. This ability to infer types makes generics more powerful and convenient to use, as it often removes the need for explicit type annotations.

Common Pitfalls and Best Practices

Overuse of Generics

While generics are a powerful feature, they can sometimes be overused, leading to code that is unnecessarily complex and difficult to understand.

function overlyGenericFunction<T, U>(param1: T, param2: U): [T, U] {
    return [param1, param2];
}

Enter fullscreen mode Exit fullscreen mode

In this example, the function is generic, but the use of two type parameters might not be necessary if the logic doesn't actually depend on them being different types. This can make the code harder to read and maintain.

Best Practice: Use generics when they provide a clear benefit, such as when the types are genuinely flexible and varied. If a specific type or a simple union type can do the job just as well, it’s often better to use those instead.

Balancing Flexibility and Complexity

Generics offer flexibility, but it’s essential to balance that flexibility with simplicity. Over-complicating code with generics can make it harder to understand and maintain.

Tips:

  • Use Generics for Reusability: If you find yourself writing the same logic for multiple types, generics can help make that code reusable.
  • Stick to Specific Types When Appropriate: If a function or class is only ever going to work with a specific type, it’s often clearer to use that specific type rather than a generic one.
  • Keep it Simple: Avoid introducing generics when a simple solution would suffice. The added complexity should be justified by the need for flexibility.

Avoiding any

Using any in TypeScript can quickly undermine the type safety that the language provides. Generics offer a safer alternative, allowing you to maintain flexibility while still benefiting from TypeScript's type system.

function logValue(value: any): void {
    console.log(value);
}

function logGenericValue<T>(value: T): void {
    console.log(value);
}

Enter fullscreen mode Exit fullscreen mode

In the first function, using any means that TypeScript will not check the type of value, potentially leading to runtime errors. In contrast, the second function uses a generic type T, preserving type safety while still being flexible.

Best Practice: Prefer generics over any whenever you need flexibility. This approach ensures that TypeScript continues to enforce type safety, reducing the risk of errors and making your code more predictable and easier to debug.

Conclusion

Generics in TypeScript are a powerful tool for creating reusable, flexible, and type-safe code. They allow you to write functions, classes, and interfaces that can work with a variety of types, reducing redundancy and enhancing code maintainability. By using generics, you can avoid the pitfalls of any, keep your code type-safe, and maintain clarity and simplicity.

Generics open up a world of possibilities in TypeScript, making it easier to write adaptable and reusable code. I encourage you to explore how generics can be applied in your projects. Whether you’re handling API responses, working with collections, or creating utility functions, generics can significantly improve your code’s flexibility and robustness.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .