Marouane Souda
⬅ Go back to blog
Discriminated unions in TypeScript: a tool that will level up your TypeScript game

Published on:

Discriminated unions in TypeScript: a tool that will level up your TypeScript game

Imagine we're building a geometry application that calculates the area of different shapes. A naive approach (how I used to do it) might look like this:

type Shape = {
    type: "circle" | "rectangle";
    width?: number;
    height?: number;
    radius?: number;
}

function getArea(shape: Shape) {
    if (shape.type === "circle") {
        return Math.PI * shape.radius ** 2
    } else if (shape.type === "rectangle") {
        return shape.width * shape.height
    } else {
        throw new Error("Invalid shape!");
    }
}

Issues with This Approach

Now, I am sure many of you reading this blogpost are familiar with this approach. You are forced to make all type properties (except for type) optional, to accomodate the different shape types, because each property may exist on one type of shape but not the other.

If we try typing this code on VSCode for example, its intellisence would not be of great help, because whenever we type in the shape variable, its autocomplete dropdown will show all of the properties of Shape type, even the ones we don't need.

Besides, this approach doesn't provide us the type safety that we usually seek by using TypeScript in our projects. So, how can we modify the code above so we can have a better type-safety and nicer help from VSCode via its Intellisence?

Introducing Discriminated Unions

To address these issues, we can use discriminated unions, which provide a structured and type-safe way to model different types.

Refactored Code with Discriminated Unions

type Shape = {
    type: "circle";
    radius: number;
} | {
    type: "rectangle",
    width: number;
    height: number;
}

function getArea(shape: Shape) {
    if (shape.type === "circle") {
        return Math.PI * shape.radius ** 2
    } else if (shape.type === "rectangle") {
        return shape.width * shape.height
    } else {
        throw new Error("Invalid shape!");
    }
}

What did we do different this time?

Well, you'll notice that we've created two different types out of the original Shape, and both were assigned to the Shape type via a union type.

Each type has its own properties that are not shared with the other, and they are not optional, but they share one essential property, the type property, which is a literal string type, and only hold one value this time.

for the circle type for example, it has the type property with value circle, and the only other property relevant to it, radius which is required to calculate the area of the circle.

The type property identifies each new shape and its own separate properties. Therefore, it is the discriminator

Why Is This Better?

inside the if statements, if you type shape.type === "", VSCode will offer you the two type values as before, circle and rectangle, but the real magic is inside the conditional statements blocks.

Try is code at VSCode, and notice intellisence doing its magic by showing only radius property inside of if (shape.type === "circle") {} block, and only width and height inside if (shape.type === "rectangle") {} block.

Additional Benefits of Discriminated Unions

1. Better Code Readability

By enforcing explicit type definitions, discriminated unions make it easier to understand what types a function can accept.

2. Extendable Code

It'll be much easier to add more shapes to the Shape type, for example a triangle, with its own specific properties, without creating an ugly mess of additional optional properties.

3. Preventing Runtime Errors

Since TypeScript can infer the exact type based on the type property, it prevents common mistakes like accessing properties that don’t exist on a given shape.

4. Leveraging TypeScript's Type Narrowing

TypeScript automatically narrows down types within switch or if statements, reducing the need for explicit type assertions.

When to Use Discriminated Unions

Discriminated unions are useful in various scenarios, including:

  • State Management: Modeling different states in a UI component (e.g., 'loading' | 'success' | 'error').
  • API Responses: Handling different response types from an API.
  • Event Handling: Processing different event types in a strongly typed way.
  • Game Development: Defining different game objects like Player | Enemy | NPC.

Conclusion

When I found out about this feature of TypeScript, I was blown away not only about its power, but also its simplicity and how easy it was to use.

I instantly thought about all of my past projects where I could use discriminated unions to refactor the codebase and make it more typesafe. useReducer action types immediately come to mind. I'll write my next blogpost about it if you hear about useReducer hook for the first time. Trust me, you'll love it 😉