← Back to blog
August 17, 2023

Exploring Discriminated Unions in TypeScript

Exploring Discriminated Unions in TypeScript

Introduction

Discriminated unions in TypeScript are an essential feature that provides an elegant way of representing data structures at the type level, code readability, and type safety. In this tutorial, we'll learn about what discriminated unions are and how to use take advantage of them.

What are Discriminated Unions?

Discriminated unions, also known as tagged unions or algebraic data types, are a TypeScript feature that enables you to create a type composed of several distinct types. These distinct types are unified under a common discriminant property, which differentiates between them.

Why Use Discriminated Unions?

  • Enhanced Type Safety: Discriminated unions provide precise type information, making it less likely to encounter runtime errors. The discriminant property acts as a type guard, enabling TypeScript to infer the specific type within the union.

  • Readable Code: Discriminated unions make your code more self-documenting by explicitly stating the different variants a value can take. This enhances code readability and maintainability.

  • Pattern Matching: Discriminated unions facilitate pattern matching, enabling you to handle different cases more elegantly and avoiding lengthy chains of if and else statements.

Creating Discriminated Unions

To create a discriminated union in TypeScript, we can follow the following steps:

  • Define an interface or type for each variant of the union.
  • Add a shared property, the discriminant, that will differentiate between the variants.
  • Create a type that combines all the individual types using the vertical bar (|) operator.

Here's a slightly modified example from the typescript official website:

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  x: number;
}

interface Triangle {
  kind: "triangle";
  x: number;
  y: number;
}

type Shape = Circle | Square | Triangle;

In this snippet, we can identify the union of three interfaces, Circle, Square, and Triangle, with each having unique properties such as radius, x, and y. And a discriminant property kind, which exists on all individual shape interfaces to enable TypeScript to narrow down the types when we access them further down in our implementation.

Pattern Matching with Discriminated Unions

Pattern matching can be achieved using a switch statement in TypeScript. The discriminant property allows the compiler to infer the specific type within each case block.

function calculateArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius * shape.radius;
  }

  if (shape.kind === "square") {
    return shape.x * shape.x;
  }

  return (shape.x * shape.y) / 2;
}

Using Discriminated Unions in React

You may have seen an implementation of a component that composes at least two other components and renders each one depending on some logic, at least I have.

If you have, you may also have noticed the slew of optional vs mandatory properties defined on the type level --- it may have looked something like this:

interface MenuItemProps {
  variant: "link" | "button";
  href?: string;
  onClick?: () => void;
  text: string;
  // ... other props
}

export function MenuItem({ variant, href, onClick, text }: MenuItemProps) {
  // ... some bit of logic goes here
  if (variant === "button") {
    return <PrimaryButton onClick={onClick}>{text}</PrimaryButton>;
  }

  return <Link href={href}>{text}</Link>;
}

And then we can render the component like this:

<MenuItem variant="button" onClick={() => {}} text="Join waitlist" />

Or like this too:

<MenuItem variant="link" href="https://example.com" text="Sign in" />

Now, technically the above implementation works and doesn't produce any errors for the most part but now we've introduced a new problem indirection with reading this component where it's been rendered.

How so? You may ask.

Well, now nothing is stopping us from doing this:

<MenuItem
  variant="button"
  href="https://example.com"
  text="Sign in"
  onClick={() => {
    console.log("clicked");
  }}
/>

Again, technically this would still render the button component under the hood and ignore any props not passed down but the point still stands that this indirection has caused us a loose autocomplete and gives a false sense of what's happening because how can a button expect a href that only works for links.

Sure, we can fix this by just rendering either a Link or PrimaryButton component where it's needed but imagine we have a couple of dozen places where we need to do this logic.

We can fix this by using discriminated unions in a central location and sharing MenuItem component across:

interface MenuItemButton {
  variant: "button";
  onClick: () => void;
  text: string;
}

interface MenuItemLink {
  variant: "link";
  href: string;
  text: string;
}

type MenuItemProps = MenuItemButton | MenuItemLink;

export function MenuItem(props: MenuItemProps) {
  // ... some bit of logic goes here
  if (props.variant === "button") {
    return <PrimaryButton onClick={props.onClick}>{props.text}</PrimaryButton>;
  }

  return <Link href={props.href}>{props.text}</Link>;
}

Here's a breakdown of what we did to improve the flexibility of this component:

  • First, MenuItemProps is now a union of two new interfaces MenuItemButton and MenuItemLink
  • We created a discriminant variant on individual interfaces to enable type narrowing

With this new implementation, we never run into an impossible state when we render MenuItem since our editor never even autocompletes for a prop that doesn't exist once we pass a variant prop.

Caveats

One thing you might have noticed in the above example is that we now have to access each prop with dot notation instead of destructuring, for example, props.variant, props.text, etc.

This is so that TypeScript can better understand what property discriminates between the union and thereby narrow down the properties that exit for that given type.

Conclusion

Discriminated unions are a useful feature of TypeScript for dealing with complex data structures while maintaining type safety and readability. And in this tutorial, we learned how to use them to produce more maintainable and error-resistant code.