Stackvora.tech

TypeScript Generics in 5 Minutes

Generics in TypeScript provide a way to create reusable components that work with a variety of types rather than a single one. This capability enhances…

Generics in TypeScript provide a way to create reusable components that work with a variety of types rather than a single one. This capability enhances code flexibility and type safety by allowing you to write functions, classes, or interfaces that can operate on any data type while still enforcing type constraints at compile time. Understanding generics is fundamental for writing robust and adaptable TypeScript applications, particularly when dealing with collections, promises, or API responses.

This article explores the core concepts of TypeScript generics, demonstrating their application in functions, interfaces, and classes. We will cover type parameters, generic constraints, and common use cases, illustrating how generics enable more flexible and maintainable code without sacrificing type accuracy.

Understanding Type Parameters

At its heart, a generic introduces a "type variable" which acts as a placeholder for a specific type. This type variable, often denoted by T (for Type), allows a component to work with a range of types. When you call a generic function or instantiate a generic class, you effectively "fill in" this type variable with a concrete type.

Consider a simple function that returns the first element of an array. Without generics, you might write it for a specific type:

function getFirstString(arr: string[]): string | undefined {
    return arr[0];
}

function getFirstNumber(arr: number[]): number | undefined {
    return arr[0];
}

This approach leads to code duplication. Generics solve this by allowing you to define a type parameter:

function first<T>(arr: T[]): T | undefined {
    return arr[0];
}

const n = first([1, 2, 3]); // Type of n is inferred as number
const s = first(["a", "b"]); // Type of s is inferred as string
const b = first([true, false]); // Type of b is inferred as boolean

// Explicitly specifying the type argument
const explicitNumber = first<number>([10, 20, 30]); // Type of explicitNumber is number

In this example, <T> declares a type variable T. When first is called with [1, 2, 3], TypeScript infers T to be number. When called with ["a", "b"], T is inferred as string. The return type also correctly reflects T.

Multiple Type Parameters

You can use multiple type parameters when a component needs to operate on different, potentially unrelated, types.

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

const mergedResult = merge({ name: "Alice" }, { age: 30 });
// Type of mergedResult is { name: string; } & { age: number; }
// which resolves to { name: string; age: number; }
console.log(mergedResult); // { name: 'Alice', age: 30 }

Generic Interfaces and Type Aliases

Generics are not limited to functions; they are incredibly useful for defining flexible interfaces and type aliases that can adapt to different data structures.

Generic Interface Example: API Response

A common scenario is an API response structure where the payload's shape changes but the overall wrapper (e.g., status, message) remains consistent.

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

// Response for a list of users
const userListResponse: ApiResponse<{ id: number; name: string }[]> = {
    statusCode: 200,
    data: [
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" }
    ]
};

// Response for a single product
const productResponse: ApiResponse<{ productId: string; price: number; description: string }> = {
    statusCode: 200,
    data: { productId: "P-456", price: 29.99, description: "Wireless Headphones" }
};

// Error response, where data might be undefined or specific error type
const errorResponse: ApiResponse<never> = { // Use 'never' if no data is expected
    statusCode: 500,
    error: "Internal Server Error"
};

const r: ApiResponse<{ id: number }> = { data: { id: 1 }, statusCode: 200 }; // As per stub example

Here, ApiResponse<T> allows the data property to hold any type T, providing strong typing for varying API payloads while maintaining a consistent response envelope.

Generic Type Alias Example: Async Operation Result

type AsyncResult<T, E = Error> = {
    status: 'success';
    data: T;
} | {
    status: 'error';
    error: E;
};

async function fetchData<T>(): Promise<AsyncResult<T>> {
    try {
        const response = await fetch('/api/data');
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data: T = await response.json();
        return { status: 'success', data };
    } catch (error) {
        // In a real application, you might map to a custom error type
        return { status: 'error', error: error as Error };
    }
}

interface User { id: number; name: string; }
const userOpResult: AsyncResult<User> = await fetchData<User>();

if (userOpResult.status === 'success') {
    console.log("User data:", userOpResult.data.name); // data is of type User
} else {
    console.error("Error:", userOpResult.error.message); // error is of type Error
}

The AsyncResult type alias uses two type parameters, T for the success data and E for the error type (defaulting to Error). This pattern is excellent for representing operations that can either succeed with data or fail with an error.

Generic Classes

Classes can also be made generic, allowing them to operate on types specified at instantiation. This is particularly useful for data structures like queues, stacks, or ORM-like repositories.

class GenericQueue<T> {
    private items: T[] = [];

    enqueue(item: T): void {
        this.items.push(item);
    }

    dequeue(): T | undefined {
        return this.items.shift();
    }

    size(): number {
        return this.items.length;
    }

    peek(): T | undefined {
        return this.items[0];
    }
}

const numberQueue = new GenericQueue<number>();
numberQueue.enqueue(10);
numberQueue.enqueue(20);
console.log(numberQueue.dequeue()); // 10 (type: number)

const stringQueue = new GenericQueue<string>();
stringQueue.enqueue("hello");
stringQueue.enqueue("world");
console.log(stringQueue.peek());    // "hello" (type: string)
// stringQueue.enqueue(123); // Type Error: Argument of type 'number' is not assignable to parameter of type 'string'.

The GenericQueue<T> class ensures that once instantiated with a specific type (e.g., number), all operations on that instance will respect that type, providing strong type checking.

Generic Constraints

Sometimes you need to ensure that a type parameter has certain capabilities or properties. This is where generic constraints come in. You can restrict the types that can be used for a type variable by using the extends keyword.

interface Lengthwise {
    length: number;
}

function logLength<T extends Lengthwise>(arg: T): T {
    console.log(arg.length); // We know T has a .length property
    return arg;
}

logLength("hello"); // Works: string has a .length property
logLength([1, 2, 3]); // Works: array has a .length property
// logLength(3);     // Error: Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.
// logLength({ value: 10 }); // Error: Property 'length' is missing in type '{ value: number; }'

The <T extends Lengthwise> constraint tells TypeScript that T must be a type that has a length property of type number. This allows you to safely access arg.length within the function.

Keyof Type Operator with Generics

A common and powerful generic constraint involves using keyof to ensure a property exists on an object.

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

const user = { id: 1, name: "Jane Doe", age: 28 };

const userName = getProperty(user, "name"); // Type of userName is string
const userId = getProperty(user, "id");     // Type of userId is number

// const userAddress = getProperty(user, "address"); // Error: Argument of type '"address"' is not assignable to parameter of type '"id" | "name" | "age"'.

Here, K extends keyof T ensures that key is a valid property name of the object obj. The return type T[K] correctly represents the type of the value at that key.

Best Practices and Considerations

  • Descriptive Type Parameter Names: While T is common for single-type generics, use more descriptive names (e.g., TValue, TKey, TItem, TError) for clarity, especially with multiple type parameters.
  • Default Types: You can provide default types for generic parameters using =, similar to default function parameters. Example: interface Cache<T = any> { /* ... */ }
  • Type Inference vs. Explicit Annotation: TypeScript is usually good at inferring generic types. Explicitly annotate when inference is incorrect, ambiguous, or when you want to enforce a stricter type than what might be inferred.
  • Readability: Don't over-engineer with generics. If a simpler type signature suffices and provides adequate type safety, prefer that. Generics add complexity.
  • Generics with JSX/TSX: When using generics in JSX/TSX, you often need a trailing comma to distinguish type parameters from JSX tags. E.g., <Component<PropType,> />. Or, for functions, call it as <SomeType>myFunction(args).

Common Pitfalls

  • Unconstrained Generics and Implicit any: If you use a generic type parameter T without any constraints and try to access properties that might not exist on T, TypeScript might implicitly treat T as any or raise a compile-time error. Always add constraints (extends) if you need to perform operations specific to certain properties or methods.
  • Losing Type Information: Be careful when transforming types within a generic function. If you cast to any or perform operations that lose type information, you undermine the purpose of generics. Strive for full type safety end-to-end.
  • Confusing Generic Types with Overloads: For simple cases where logic differs based on a few distinct types, overloads might seem easier. However, generics are more scalable and maintainable for components that need to work uniformly across a wide range of types.
  • Not Specifying Type Arguments When Needed: While TypeScript can often infer generic type arguments, there are cases where it cannot (e.g., when passing an empty array, [], to a function expecting T[]). In such situations, explicitly provide the type argument: first<string>([]).

Back to the knowledge base · Ask the AI assistant