Page Content

Tutorials

Why use Generics in TypeScript? With Examples

Generics in TypeScript

In order to accomplish polymorphism, TypeScript’s sophisticated generics feature allows programmers to create incredibly adaptable and reusable code that is type-safe for a wide range of data types. The primary mechanism of generics is the introduction of type parameters often symbolised by angle brackets containing a capital letter, such as <T> to define meaningful constraints between related members.

The main reason for utilising generics is that a lot of data structures and algorithms function regardless of the kind of data they handle. Rather, they depend on imposing a significant restriction: that input and output types, or pushed and popped types, must be the same within the definition.

Generic Functions

At the function level, generics are commonly used to guarantee that input types correspond to output types. Regardless of whether the list comprises texts, numbers, or objects, a function that reverses the list’s items is a common example for beginners. Here, the only restriction is that the things returned have to be of the same type as the ones that were entered.

In a generic function, the type parameter <T> is declared immediately after the function name. This signals to TypeScript that the function uses a placeholder type T internally.

Consider the reverse function example:

function reverse<T>(items: T[]): T[] {
    var toreturn = [];
    for (let i = items.length - 1; i >= 0; i--) {
        toreturn.push(items[i]);
    }
    return toreturn;
}

var sample = ;
var reversed = reverse(sample);
console.log(reversed); // Output: 3,2,1

// Safety check: TypeScript knows 'reversed' is number[]
reversed = '1';       // Error!
reversed = 1;         // Okay
reversed = ;       // Okay

In the example above, by defining reverse<T>(items: T[]): T[], you are explicitly stating that the function accepts an array of some type T and guarantees to return an array of that exact same type T. Because the input array sample is inferred as number[], TypeScript binds T to number. Consequently, the reversed variable is also typed as number[], providing type safety by preventing the assignment of a string (like '1') to an array element.

Generic Classes

Equally crucial are generics for building data structures such as interfaces or classes. They cover the drawback of non-generic languages, where it may be necessary to define distinct classes (such as QueueNumber and QueueString) in order to meet particular type constraints.

At the class level, you can declare that the class runs on a consistent type T throughout its implementation by using a generic argument.

Here is an implementation of a generic Queue class:

class Queue<T> {
    private data = [];

    push(item: T) { 
        this.data.push(item); 
    }

    pop(): T | undefined { 
        return this.data.shift(); 
    }
}

/** Usage and Output Analysis */
const queue = new Queue<number>();
queue.push(0);

// queue.push("1"); // This line results in an ERROR! 
// TypeScript knows 'queue' is a Queue of numbers, 
// so pushing a string is disallowed.

// If the error above is fixed, the following is safe:
const item = queue.pop();
console.log(item); // Output: 0 
// 'item' is safely inferred as 'number | undefined'.

The definition class Queue<T> enables the class members, like push(item: T) and pop(): T | undefined, to use the generic type T. When an instance is created, such as new Queue<number>(), the type parameter T is bound to number for that instance, ensuring that only numerical items can be pushed and popped.

Constraints ()

Although generics provide freedom, you frequently have to limit the types that T can represent, possibly because the function or class must manipulate T in a certain way. This is accomplished by utilising the extends keyword in conjunction with constraints.

Since a constraint establishes an upper restriction for the generic type parameter, T must either be the type that is defined or a subtype of it. TypeScript cannot ensure that a property (such as .length or .run()) exists on an unbound generic T in the absence of constraints, which can lead to a compile-time error.

By extending an interface, you can utilise the members described in that interface safely within your generic logic since you can make sure that any type tied to T follows the necessary structure.

To enable a function to securely execute an action defined by an IRunnable interface, for instance, you would require T to extend that interface:

interface IRunnable {
    run(): void;
}
 
/** 
 * T is constrained to IRunnable, meaning it must have a run() method. 
 * <T extends IRunnable> 
 */
function runSafe<T extends IRunnable>(runnable: T): void {
    try {
        // Safe because T is guaranteed to have the run() method
        runnable.run(); 
    } catch(e) {
        // Handle runtime error
    }
}

class SystemTask implements IRunnable {
    run() {
        console.log("System Task initiated.");
    }
}

// Usage with a valid type:
runSafe(new SystemTask()); 
// Output: System Task initiated.

// Usage with an invalid type (lacking the run() method):
// runSafe({ a: 1 }); // Error! Argument is not assignable to IRunnable

In this example, the constraint <T extends IRunnable> forces the runnable argument to possess the run(): void signature. This is an instance of bounded polymorphism, where the input type T is restricted but preserves the specific type information of the class passed in. Complex constraints can also be defined using intersection types (&) or referencing other type parameters within the constraint list.

Type Inference in Generics

Performing type inference, which frequently eliminates the need for explicit type annotations, is one of TypeScript’s advantages. The ability to infer the precise concrete type that ought to be bound to a generic type parameter T is effortlessly transferred to generics by TypeScript’s type checker.

TypeScript analyses the types of the arguments passed in to a generic function to find the proper type binding. TypeScript determines that T is a number, for example, when it calls a generic map function with an array of numbers.

In the reverse(sample) call demonstrated earlier, even though reversed lacked an explicit type annotation, TypeScript successfully inferred T as number based on the input sample: number[].

In scenarios where explicit binding is needed for example, when declaring a variable that holds a generic class type you must provide the generic type argument explicitly (e.g., const queue = new Queue<number>()). Inference typically takes care of this automatically, though, whether you call a generic function or use an array method like .reverse(). A compilation error will occur if you try to annotate only a partial list of generic types; if you do explicitly annotate generic types in a function call, you must provide all necessary generic arguments.

Generics are essential tools for creating scalable, reliable, and maintainable programs because they define highly reusable code patterns and are backed by contextual type inference and strong restrictions.

Index