Making Typescript More Flexible: Generics and Discriminated Unions

Making Typescript More Flexible: Generics and Discriminated Unions

In the evolving landscape of software development, TypeScript stands tall as a beacon of hope for developers seeking type safety amidst the wild west of JavaScript. However, as many delve deeper into the vast terrain of TypeScript, they often find themselves resorting to the "any" or "unknown" types when faced with complex typing scenarios. One might speculate that this fallback happens not due to TypeScript's limitations, but rather because developers might not be aware of some of the language's powerful features. Enter Generics and Discriminated Unions—two power-packed features of TypeScript that answer this challenge. These constructs not only enrich the language by providing a dynamic way to work with types but also push the boundaries of what's possible, eliminating the need for overly broad type definitions. In this guide, we'll embark on an enlightening journey to demystify these concepts, empowering you to make TypeScript more adaptable and precise than ever before.

Flow diagram showing the relationships between type guards, generics and discriminated untions


Generics

Generics offer a way to create reusable components in TypeScript by allowing a type to be specified later, at the time of use, rather than being hardcoded. This provides the flexibility to work with any type while still preserving the benefits of strong typing. Essentially, generics allow you to write a function or a class that can operate on a variety of data types instead of being restricted to a single one.

Example:

Let's create a React component called GenericForm:

import React, { useState } from 'react';

interface FormProps<T> {
    initialValues: T;
    onSubmit: (values: T) => void;
    children: (values: T, handleChange: (key: keyof T, value: any) => void) => JSX.Element;
}

function GenericForm<T>(props: FormProps<T>) {
    const [values, setValues] = useState<T>(props.initialValues);

    const handleChange = (key: keyof T, value: any) => {
        setValues(prev => ({ ...prev, [key]: value }));
    };

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        props.onSubmit(values);
    };

    return (
        <form onSubmit={handleSubmit}>
            {props.children(values, handleChange)}
            <button type="submit">Submit</button>
        </form>
    );
}

// Usage
type FormData = {
    name: string;
    age: number;
};

function App() {
    const initialValues: FormData = { name: '', age: 0 };

    const handleSubmit = (values: FormData) => {
        console.log("Submitted values:", values);
    };

    return (
        <GenericForm initialValues={initialValues} onSubmit={handleSubmit}>
            {(values, handleChange) => (
                <div>
                    <input 
                        type="text" 
                        value={values.name} 
                        onChange={e => handleChange('name', e.target.value)} 
                        placeholder="Name"
                    />
                    <input 
                        type="number" 
                        value={values.age} 
                        onChange={e => handleChange('age', Number(e.target.value))} 
                        placeholder="Age"
                    />
                </div>
            )}
        </GenericForm>
    );
}

export default App;

The Generic Component

The heart of this example is the GenericForm component. This component is defined to be generic, as denoted by the <T> after its name.

function GenericForm<T>(props: FormProps<T>) {...}

This means that it can operate on any type T. The form doesn't need to know what this type is in advance, which is why it's so flexible.

FormProps Interface

The props for the GenericForm component are defined using the FormProps interface:

interface FormProps<T> {
    initialValues: T;
    onSubmit: (values: T) => void;
    children: (values: T, handleChange: (key: keyof T, value: any) => void) => JSX.Element;
}

This interface describes:

  • initialValues: This is the starting state of the form and will be of type T.

  • onSubmit: A callback that will receive the current form values (of type T) when the form is submitted.

  • children: A function, often termed a "render prop". It receives the current form values and a handleChange function to modify these values. It then returns JSX, which describes the form fields.

Form State & Handlers

Inside the GenericForm component, we use the useState hook to maintain the current form values:

const [values, setValues] = useState<T>(props.initialValues);

The handleChange function allows any value within our state (of type T) to be changed:

const handleChange = (key: keyof T, value: any) => {
    setValues(prev => ({ ...prev, [key]: value }));
};

Here, keyof T ensures that the provided key is a valid property of our data type T.

Usage of the GenericForm

In the App component, we see an example of how to use the GenericForm:

type FormData = {
    name: string;
    age: number;
};

Here, FormData is a specific type representing the structure of our form data.

In the JSX for the App component, the GenericForm component is used with this specific FormData type. The form fields (name and age) are provided as children, and they interact with the form's state using the provided handleChange function.

Flexibility of the Component

The beauty of this setup is in its flexibility. You can use the GenericForm component with any data structure, not just FormData. If you had a different form structure, you'd define a new type, provide appropriate initial values, and adjust the form fields in the children function accordingly.


Discriminated Unions

Discriminated Unions, sometimes referred to as "tagged unions" or "algebraic data types", are an advanced feature in TypeScript that allows the creation of objects which could be of multiple types. However, unlike traditional unions, a discriminated union has a common key across all the types that lets you safely discriminate one type from the other.

Basic Concept:

A discriminated union requires:

  1. Types that have a common, singular property (the discriminant).

  2. A type alias that represents the union of these types.

  3. Type narrowing using the discriminant to differentiate between the types.

Example:

Suppose you are developing a chat application and you want to support various message types: text, image, and audio. Here's how you can represent this using discriminated unions in TypeScript:

// 1. Types with a common discriminant (`type` in this case)
type TextMessage = {
    type: 'text';
    content: string;
}

type ImageMessage = {
    type: 'image';
    imageUrl: string;
    caption?: string;
}

type AudioMessage = {
    type: 'audio';
    audioUrl: string;
    duration: number;
}

// 2. Type alias for the union of message types
type Message = TextMessage | ImageMessage | AudioMessage;

// 3. Type narrowing using the discriminant
function displayMessage(message: Message) {
    switch (message.type) {
        case 'text':
            console.log(`Text Message: ${message.content}`);
            break;
        case 'image':
            console.log(`Image Message with URL: ${message.imageUrl}. Caption: ${message.caption || 'None'}`);
            break;
        case 'audio':
            console.log(`Audio Message of duration ${message.duration} seconds. URL: ${message.audioUrl}`);
            break;
    }
}

In this example:

  • The type property acts as the discriminant. Each message type has a unique type value: 'text', 'image', or 'audio'.

  • The Message type represents any message, but the discriminant ensures that you always know which specific type of message you are dealing with.

  • The displayMessage function uses type narrowing with a switch statement on the type property. Inside each case block, TypeScript knows the exact type of message, allowing you to safely access properties specific to each type without any type errors.

In essence, discriminated unions provide a robust mechanism to handle variants, ensuring type safety while keeping the code elegant and error-free.


Type Guards

In TypeScript, while types help in establishing contracts across your code, it's also essential to verify or narrow down specific types during runtime, especially when dealing with conditional scenarios. This is where type guards come into play. A type guard is essentially a runtime check that guarantees the type in a particular scope.

Basic JavaScript Checks as Type Guards:

Before diving into advanced type guards, recognize that basic JavaScript checks often serve as type guards:

typeof

Works with primitive types such as strings, numbers, and booleans.

if (typeof value === "string") {
    console.log(value.split(" "));  // TypeScript knows `value` is a string here.
}

instanceof

Works with class instances.

class Car {
    drive() {
        console.log("Driving a car");
    }
}

class Boat {
    sail() {
        console.log("Sailing a boat");
    }
}

function move(vehicle: Car | Boat) {
    if (vehicle instanceof Car) {
        vehicle.drive();
    } else {
        vehicle.sail();
    }
}

User-defined Type Guards:

These are custom functions whose return type is a type predicate that narrows the type using the is keyword:

function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

// Using the type guard
if (isFish(myPet)) {
    myPet.swim();
} else {
    myPet.fly();
}

If isFish returns true, TypeScript knows the type of myPet is Fish within that block.

Literal Type Guards:

With discriminated unions, the unique property value (the discriminant) can serve as a literal type guard:

type Action =
    | { type: "increment", amount: number }
    | { type: "decrement", amount: number };

function performAction(action: Action) {
    switch(action.type) {
        case "increment":
            // action is of type { type: "increment", amount: number }
            return counter + action.amount;
        case "decrement":
            // action is of type { type: "decrement", amount: number }
            return counter - action.amount;
    }
}

Using in Operator as Type Guard:

The in operator can be used as a type guard to narrow down a type based on the presence of certain properties:

function move(animal: Bird | Fish) {
    if ("swim" in animal) {
        animal.swim();
    } else {
        animal.fly();
    }
}

Final Considerations

Mastering TypeScript's type system is both an art and a science. While the initial draw to TypeScript may be its static typing capabilities, developers soon realize that its true power is unlocked through features like generics, discriminated unions, and type guards. By understanding and utilizing these advanced concepts, developers can construct robust, maintainable, and type-safe applications, even in the face of intricate and complex use cases. With generics, you gain the ability to build reusable and type-flexible constructs; with discriminated unions, you can craft objects that might belong to multiple types but can be easily distinguished; and with type guards, you assure that types are what you expect them to be during runtime, enhancing both safety and clarity.

However, as with all powerful tools, the key is in knowing when and how to use them. Over-reliance on any single tool can lead to over-complicated solutions. By combining these advanced TypeScript features with a thorough understanding of the problem space, developers can strike a balance, producing code that's both elegant and type-safe. As you continue your journey with TypeScript, may you leverage these features to their fullest, bringing precision, clarity, and robustness to all your projects.


Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.