DEV Community

Cover image for Mastering Discriminated Unions in TypeScript
Anton Martyniuk
Anton Martyniuk

Posted on • Originally published at antondevtips.com on

Mastering Discriminated Unions in TypeScript

Originally published at https://antondevtips.com.

What Are Discriminated Unions

Often, applications require variables and functions that have multiple types of input or output.
TypeScript has a special Union Type for such use cases, called Discriminated Unions.

Discriminated Unions in TypeScript are formed from two or more other types.
A variable of discriminated union can be one of the types from the union.

Here's a simple example where a variable can hold either a number or a string:

let id: number | string;
id = 100; // This code is correct
id = "200"; // This code is correct

// TypeScript Error: Type 'boolean' is not assignable to type 'string | number'.
id = false;
Enter fullscreen mode Exit fullscreen mode

Unions are also useful when combining string literals. A literal in TypeScript is a special type for constant string, for example:

let text: "TypeScript" = "TypeScript";
text = "TypeScript"; // This code compiles

// TypeScript Error: Type '"JavaScript"' is not assignable to type '"TypeScript"'.
text = "JavaScript";
Enter fullscreen mode Exit fullscreen mode

By combining literals into unions you can express that a variable can only accept a certain set of values:

function print(value: string, align: "left" | "right" | "center") {
    // ...
}

print("TypeScript types", "left");

// TypeScript Error: "align" parameter should be one of: "left" | "right" | "center"
print("TypeScript types", "middle");
Enter fullscreen mode Exit fullscreen mode

When using discriminated unions, TypeScript will only allow to access properties or methods that are common to all types in the union.
To use type-specific properties or methods, you need to use type guards for built-in types:

function getLength(obj: string | string[]) {
    if (typeof obj === "string") {
        return obj.length; // Return length of a string
    }

    return obj.length; // Return legnth of string array
}
Enter fullscreen mode Exit fullscreen mode

Discriminated Unions With Object Types

Discriminated Unions can combine object types too.
Let's explore an example of geometrics shapes, that have common and different properties:

type Circle = {
    type: "circle";
    radius: number;
};

type Square = {
    type: "square";
    size: number;
};
Enter fullscreen mode Exit fullscreen mode

Let's create a function that calculates a square for each shape:

function getSquare(shape: Circle | Square) {
    if (shape.type === "circle") {
        return Math.PI * shape.radius * shape.radius;
    }

    if (shape.type === "square") {
        return shape.size * shape.size;
    }
}

const circle: Circle = {
    type: "circle",
    radius: 10
};

const square: Square = {
    type: "square",
    size: 5
};

console.log("Circle square: ", getSquare(circle));
console.log("Circle square: ", getSquare(square));
Enter fullscreen mode Exit fullscreen mode

Here a getSquare function accepts a shape parameter that can be one of 2 types: Circle or Square.
When working with custom types you can use a property that will allow to distinguish types from each other.
In our example it's a type property.
Type guards here are straightforward:

function getSquare(shape: Circle | Square) {
    if (shape.type === "circle") {
        return Math.PI * shape.radius * shape.radius;
    }

    if (shape.type === "square") {
        return shape.square;
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript is smart enough to know that we are working with Circle or Square type.

Using Discriminated Unions as Function Return Type

Another example where discriminated unions are useful - is a function return type.
Let's explore an example of placing an order for a product by its ID and desired quantity.
The return type of this function will be a discriminated union representing three possible states:

  • order successfully created
  • product not found
  • not enough product quantity in the warehouse

The naive approach is to declare a big object with properties that cover all three cases.
But such implementation is cumbersome.

Discriminated unions offer a more elegant approach:

type OrderResponse = OrderCreated | ProductNotFound | NoProductQuantity;

type OrderCreated = {
    type: "order-created";
    orderId: string;
    message: string;
};

type ProductNotFound = {
    type: "product-not-found";
    message: string;
};

type NoProductQuantity = {
    type: "no-product-quantity";
    message: string;
};
Enter fullscreen mode Exit fullscreen mode

Now we can use this union type as a function's return type.
We are getting products from a database or an external API, but for simplicity we will mock the data:

function orderProduct(productId: string, quantity: number): OrderResponse {
    const productsDatabase = {
        "Mobile Phone": { stock: 10 },
        "Smart TV": { stock: 0 },
    };

    const product = productsDatabase[productId];

    if (!product) {
        return {
            type: "product-not-found",
            message: "Product not found"
        };
    }

    if (quantity > product.stock) {
        return {
            type: "no-product-quantity",
            message: "Insufficient product quantity"
        };
    }

    return {
        type: "order-created",
        orderId: `order-${new Date().getTime()}`,
        message: "Order has been successfully created"
    };
}
Enter fullscreen mode Exit fullscreen mode

In this function we are returning 3 types of responses: "order-created", "product-not-found", "no-product-quantity".
To order a product let's call the method:

const orderResult = orderProduct("Mobile Phone", 1);

switch (orderResult.type) {
    case "order-created":
        console.log(`Success: ${orderResult.message} (Order ID: ${orderResult.orderId})`);
        break;
    case "product-not-found":
        console.log(`Error: ${orderResult.message}`);
        break;
    case "no-product-quantity":
        console.log(`Error: ${orderResult.message}`);
        break;
}
Enter fullscreen mode Exit fullscreen mode

As you can see discriminated unions make code more readable and maintainable in such use cases.

Summary

Discriminated unions in TypeScript offer a robust way to manage different data types under a united type.
They enhance type safety, improve code clarity, and ensure that all possible cases are handled, reducing runtime errors.
Using discriminated unions as function input and output types will significantly improve the quality and maintainability of your TypeScript applications.

Hope you find this blog post useful. Happy coding!

Originally published at https://antondevtips.com.

After reading the post consider the following:

  • Subscribe to receive newsletters with the latest blog posts
  • Download the source code for this post from my github (available for my sponsors on BuyMeACoffee and Patreon)

If you like my content —  consider supporting me

Unlock exclusive access to the source code from the blog posts by joining my Patreon and Buy Me A Coffee communities!

Top comments (0)