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.

Generic Interfaces

In TypeScript, interfaces are mostly used to specify the structure (or “shape”) of objects and combine several type annotations into a single named declaration. Interfaces can define a flexible blueprint that adjusts to the particular data types it handles by utilising generics to receive polymorphic parameters.

Declaring Generic Interfaces

A generic interface is declared by following the interface name with one or more type parameters surrounded in angle brackets (<>). When the interface is later utilised or implemented, these parameters T, U, etc. act as placeholder types that are tied to concrete types.

It is customary to use single uppercase letters beginning with T (for “Type”), followed by U, V, and so forth. However, when utilising several generic arguments, descriptive names such as TKey and TValue are recommended for clarity.

Example 1: Single Generic Parameter

An interface can define a result structure where the type of the error property is flexible:

interface IResult<T> {
    wasSuccessful: boolean;
    error: T;
}

When using this interface, the developer explicitly specifies the type that T represents:

var result: IResult<string>;
// ... assignment logic
var error: string = result.error;

Example 2: Multiple Generic Parameters

Interfaces can also define relationships between multiple types, such as defining a runnable operation that takes an input type (T) and returns an output type (U):

interface IRunnable<T, U> {
    run(input: T): U;
}

var runnable: IRunnable<string, number>;
var input: string;
var result: number = runnable.run(input);

Implementing Generic Interfaces

Classes can implement a generic interface. The class may be non-generic (binding the type argument to a concrete type) or generic (propagating the type parameter).

Consider a generic interface IEvents<T> for handling a list of events of type T:

interface IEvents<T> {
    list: T[];
    emit(event: T): void;
    getAll(): T[];
}

A generic class implementing this interface:

// Generic class implementation
class State<T> implements IEvents<T> {
    list: T[];

    constructor() {
        this.list = [];
    }

    emit(event: T): void {
        this.list.push(event);
    }

    getAll(): T[] {
        return this.list;
    }
}

Code Usage and Output (Illustrative Errors):

Using the generic State class with a complex type constraint (IStatus<number>):

interface IStatus<U> {
    code: U;
}

// Instance 1: T is resolved to IStatus<number>
const s = new State<IStatus<number>>(); 
s.emit({ code: 200 }); // OK: code property expects a number

// s.emit({ code: '500' }); // Error: Type 'string' is not assignable to type 'number' 
s.getAll().forEach(event => console.log(event.code));

Expected Console Output (Conceptual):

200

When designing statically typed code, generic interfaces are a helpful tool since they provide flexibility while upholding stringent type verification.

Generic Constraints

Even while generics let you build algorithms that don’t depend on the type (T), you frequently still need to make sure that T has a certain set of characteristics in order for the algorithm to work properly. Generic constraints are used to enforce this criterion.

The constrained type must be a subtype of the given constraint since constraints set an upper bound on the type parameter. The generic declaration’s extends keyword is used to do this.

Defining Simple Constraints

The types that can be used in place of the generic parameter T are limited by the extends keyword, which is enclosed in angle brackets.

Example: Constraint enforcing a method signature

You can define an interface (IRunnable) that requires the run() method to be executed on an input object if your generic runner class or function needs to do so. Next, IRunnable is implemented by constraining the generic type T:

// 1. Define the required capabilities (the constraint structure)
interface IRunnable {
    run(): void;
}

// 2. Apply the constraint to the generic type T
interface IRunner<T extends IRunnable> {
    runSafe(runnable: T): void;
}

// 3. Implementation of a class that satisfies the constraint
class MyTask implements IRunnable {
    run() {
        console.log("Task running...");
    }
}

// 4. Usage in a concrete runner class
class Runner implements IRunner<MyTask> {
    runSafe(runnable: MyTask): void {
        try {
            runnable.run();
        } catch (e) {
            // Handle error
        }
    }
}

TypeScript will generate a compile-time error if you try to use IRunner with a type that doesn’t meet the IRunnable requirement.

It is also possible to define constraints inline, explicitly defining the necessary shape:

interface IRunnableInline<T extends { run(): void }> {
    runSafe(runnable: T): void;
}

Constraints referencing other Type Parameters

Other type parameters defined in the same list may likewise be referenced by constraints. This makes it possible to have intricate connections in which two generic types need to be architecturally compatible.

The target object (T) must have at least the same attributes as the source object (U) in order for the assign function to work, which replicates properties from the source object to the target object (target: T):

function assign<T extends U, U>(target: T, source: U): T {
    for (let id in source) {
        // T must contain all properties of U
        (target as any)[id] = source[id];
    }
    return target;
}

Code Usage and Output:

let x = { a: 1, b: 2, c: 3, d: 4 };

// Usage 1: U = { b: number, d: number }. T ({...}) extends U. OK.
assign(x, { b: 10, d: 20 }); 

// console.log(x); // Output: { a: 1, b: 10, c: 3, d: 20 }

// Usage 2: U = { e: number }. T ({...}) does NOT extend U because 'e' is missing in T. Error.
// assign(x, { e: 0 }); // Error: Argument of type '{ e: number; }' is not assignable to parameter of type 'U' 

Expected Console Output (after running assign(x, { b: 10, d: 20 });):

// Assuming console output of x:
{ a: 1, b: 10, c: 3, d: 20 }

This guarantees that the compiler avoids possible runtime problems in which a property from the source object may not be able to be received by the target object.

Index