TypeScript Generics and Union Types: A Complete Guide

Updated: January 7, 2024 By: Guest Contributor Post a comment

Introduction

TypeScript enhances JavaScript with types, and among its powerful features are generics and union types. This guide dives deep into the concepts and practical applications of these features to produce robust and reusable code.

Understanding Generics

Generics in TypeScript enable the creation of components that work with any data type while still providing compile-time type checking. Look at the following example:

function getArray<T>(items : T[] ) : T[] {
    return new Array().concat(items);
}

let myNumArr = getArray<number>([10, 20, 30]);
let myStrArr = getArray<string>(['Hello', 'World']);

Here, <T> is a type variable that can be replaced with any type (number, string, etc.) when the function is called.

Union Types

Union types are a way of declaring a variable that can hold values of two or more different types. It’s represented using the pipe symbol (|). For instance:

function printId(id: number | string) {
  console.log('Your ID is: ' + id);
}

printId(101); // OK
printId('202'); // OK
//printId({ myID: 22422 }); // Error

This function accepts both number and string as types for the id parameter.

Combining Generics with Union Types

Generics and union types can be combined to provide even more flexibility in your functions:

function merge<T, U>(objA: T, objB: U): T & U {
    return Object.assign(objA, objB);
}

let result = merge({ name: 'John' }, { age: 30 });
// result is of type { name: string; age: number; }

This example merges two objects into one with properties from both types. The result is an intersection type of the input types.

Generic Constraints and Union Types

Sometimes you may need to constrain the types you want to apply to generics, to ensure better type safety:

function logAndReturn<T extends { toString: () => string }>(elem: T): T {
  console.log(elem.toString());
  return elem;
}

logAndReturn(123); // Error
logAndReturn(new Date()); // OK

The <T extends { toString: () => string }> ensures that T has a toString method.

Conditional Types and Union Types

Conditional types enable you to work with types based on conditions, refining the way you can combine generics and union types:

type NonNullable<T> = T extends null | undefined ? never : T;

function getValue<T>(value: T): NonNullable<T> {
    if (value == null) {
        throw new Error('Value must not be null or undefined');
    }
    return value as NonNullable<T>;
}

getValue('string'); // OK
// getValue(null); // Error

Utility Types with Generics

TypeScript provides several utility types to make working with generics more convenient. One of the most common is Partial<T>, which makes all properties of T optional:

interface User {
    name: string;
    age: number;
}

type PartialUser = Partial<User>;

// You can now create a PartialUser with just one of the two properties defined
let user: PartialUser = { name: 'Alice' };

Advanced Generic Patterns

You can also create more advanced generic structures:

type LinkedList<T> = T & { next: LinkedList<T> } | null;

function insertAtBeginning<T>(head: LinkedList<T>, element: T): LinkedList<T> {
    return { ...element, next: head };
}

This pattern allows creating a linked list with any type of elements.

Practical Use Cases

Combining generics and union types is not just for academic use cases, but they can be applied to practical scenarios. Imagine you are working with an API and you want to handle different response types:

type Response = UserResponse | ProductResponse | ErrorResponse;

function handleResponse(response: Response) {
    if ('username' in response) {
        // Handle UserResponse:
        ...
    } else if ('productName' in response) {
        // Handle ProductResponse:
        ...
    } else {
        // Handle ErrorResponse:
        ...
    }
}

This structure enables the function to correctly handle different types of responses based on the presence of certain fields.

Summary

This guide outlined the power and flexibility of TypeScript’s generics and union types. By leveraging these features, you can build complex, high-quality type systems that promote code reuse and maintainability.