Diving Deeper into Generics in TypeScript

bilel salem - Jun 13 - - Dev Community

Hello everyone, السلام عليكم و رحمة الله و بركاته

Generics in TypeScript offer more than just a way to write flexible and reusable code; they provide a mechanism to create sophisticated and type-safe abstractions. By exploring advanced generic concepts, constraints, and real-world applications, you can unlock the full potential of TypeScript's type system.

Advanced Generic Function Patterns

Let's consider some more advanced patterns with generic functions to see how they can be utilized effectively.

Multiple Type Parameters

A function can accept multiple generic parameters, which is particularly useful when dealing with pairs or tuples of values.

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

const person = { name: 'Bilel' };
const job = { title: 'Developer' };
const employee = merge(person, job);
Enter fullscreen mode Exit fullscreen mode

In this example, merge combines two objects into one, preserving the types of both input objects.

Generic Constraints with Multiple Types

By using constraints, you can ensure that generic parameters have certain properties or methods, making your functions safer and more predictable.

interface Lengthwise {
    length: number;
}

function loggingLength<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);
    return arg;
}

loggingLength('Hello'); // OK
loggingLength([1, 2, 3]); // OK
loggingLength({ length: 10, value: 'Test' }); // OK
loggingLength(3); // ERROR
Enter fullscreen mode Exit fullscreen mode

Here, the loggingLength function is constrained to types that have a length property, ensuring that the function can safely access length on its argument.

Generic Classes and Inheritance

Generics can be combined with class inheritance to create powerful, reusable components.

class DataHolder<T> {
    private data: T;

    constructor(data: T) {
        this.data = data;
    }

    getData(): T {
        return this.data;
    }

    setData(data: T): void {
        this.data = data;
    }
}

class StringDataHolder extends DataHolder<string> {
    constructor(data: string) {
        super(data);
    }

    getUpperCaseData(): string {
        return this.getData().toUpperCase();
    }
}

const stringHolder = new StringDataHolder('hello');
console.log(stringHolder.getUpperCaseData()); // HELLO
Enter fullscreen mode Exit fullscreen mode

In this example, DataHolder is a generic class, and StringDataHolder extends it to provide additional functionality specific to strings.

Generic Interfaces and Type Aliases

Generics are often used with interfaces and type aliases to create flexible data structures and function signatures.

interface Repository<T> {
    getById(id: string): T;
    save(entity: T): void;
}

class UserRepository implements Repository<User> {
    private users: Map<string, User> = new Map();

    getById(id: string): User {
        return this.users.get(id);
    }

    save(user: User): void {
        this.users.set(user.id, user);
    }
}

type User = {
    id: string;
    name: string;
};

const repo = new UserRepository();
repo.save({ id: '1', name: 'Bilel' });
console.log(repo.getById('1')); // { id: '1', name: 'Bilel' }
Enter fullscreen mode Exit fullscreen mode

This example shows a generic Repository interface that can be implemented for any type, and a specific UserRepository that handles User entities.

Keyof and Lookup Types

TypeScript's keyof operator and lookup types allow for even more powerful generic constructs.

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

const person = { name: 'Bilel', age: 23 };
const name = getProperty(person, 'name'); // Bilel
const age = getProperty(person, 'age'); // 23
const wrongProp = getProperty(person, 'weight'); // ERROR in typescript : it will be underlined with red line .
Enter fullscreen mode Exit fullscreen mode

Here, getProperty uses keyof to ensure that the key parameter is a valid key of the obj parameter, and returns the correct type.

Conditional Types

Conditional types provide a way to express more complex type relationships.

type MessageOf<T> = T extends { message: unknown } ? T['message'] : never;

interface Email {
    message: string;
}

interface SMS {
    message: string;
}

type EmailMessageContents = MessageOf<Email>; // string
type SMSMessageContents = MessageOf<SMS>; // string
type NumberMessageContents = MessageOf<number>; // never
Enter fullscreen mode Exit fullscreen mode

In this example, MessageOf is a conditional type that extracts the message property type if it exists, or never otherwise.

Real-World Applications

Generics are prevalent in real-world TypeScript applications, from complex data handling to API integrations and beyond.

Data Transformation Utilities

Utility functions that transform data structures often benefit from generics.

function mapArray<T, U>(arr: T[], transform: (item: T) => U): U[] {
    return arr.map(transform);
}

const numbers = [1, 2, 3];
const strings = mapArray(numbers, num => num.toString());
Enter fullscreen mode Exit fullscreen mode
API Response Handling

When dealing with API responses, generics can ensure type safety across different endpoints.

async function fetchJson<T>(url: string): Promise<T> {
    const response = await fetch(url);
    return response.json();
}

interface User {
    id: string;
    name: string;
}

const user = await fetchJson<User>('/api/user/1');
console.log(user.name);
Enter fullscreen mode Exit fullscreen mode

Conclusion

By exploring and mastering the use of generics, including advanced patterns and constraints, you can significantly elevate the quality and efficiency of your TypeScript projects. This deeper understanding not only improves your current work but also prepares you to tackle future challenges with confidence and skill .

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