All Articles

TypeScript Discriminated Unions for Frontend Developers

Matt Pocock
Matt PocockMatt is a well-regarded TypeScript expert known for his ability to demystify complex TypeScript concepts.

As a frontend developer, your job isn't just pixel-pushing. Most of the complexity in frontend comes from handling all the various states your app can be in.

It might be loading data, waiting for a form to be filled in, or sending a telemetry event - or all three at the same time.

If you aren't handling your states properly, you're likely to come unstuck. And handling states starts with how they're represented in types.

'Bag of optionals'

Let's imagine you're building a simple data loader. You might choose to use a type like this to represent its state:

interface State {
  status: "loading" | "error" | "success";
  error?: Error;
  data?: { id: string };
}

// Some examples:
const example: State = {
    status: "loading"
};

const example2: State = {
    status: "error",
    error: new Error("Oh no!")
};

This seems pretty nice - we can check status to understand what kind of UI we should display on the screen.

Except - this type lets us declare all sorts of shapes which should be impossible:

const example3: State = {
  status: "success",
  // Where's the data?!
};

Here, we're in a success state - which should let us access our data. But it doesn't exist!

const example4: State = {
  status: "loading",
  // We're loading, but we still have an error?!
  error: new Error("Eek!"),
};

And here, we're in a loading state - but there's still an error in our data object!

This is because we've chosen to represent our state using what I call a 'bag of optionals' - an object full of optional properties.

Optional properties are best used when a value might or might not be present. In this case, that isn't right.

  • When status is loading, data or error are never present.
  • When status is success, data is always present.
  • When status is error, error is always present.

Discriminated unions

The more accurate way to represent this is using a discriminated union.

Let's start by changing our state to be a union of object, each containing a status.

type State =
  | {
      status: "loading";
    }
  | {
      status: "success";
    }
  | {
      status: "error";
    };

Now that we've got our scaffolding, we can start adding elements to each branch of the union. Let's re-add our error and data types.

type State =
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: {
        id: string;
      };
    }
  | {
      status: "error";
      error: Error;
    };

Now, our examples from above will start erroring.

// Error: Property 'data' is missing
const example3: State = {
  status: "success",
};

const example4: State = {
  status: "loading",
  // Error: Object literal may only specify known
  // properties, and 'error' does not exist
  error: new Error("Eek!"),
};

Our State type now properly represents all the possible states of the feature. That's a big step forward, but we're not done yet.

Destructuring Discriminated Unions

Let's imagine we're inside a component in our codebase. We've received our piece of state, and we're looking to use it to render some JSX.

I'll use React here, but this could be any frontend framework.

The first instinct of many developers will be to destructure the elements of State, but you'll immediately hit errors:

const Component = () => {
  const [state, setState] = useState<State>({
    status: "loading",
  });

  const {
    status,
    // Error: Property 'data' does not exist on type 'State'.
    data,
    // Error: Property 'error' does not exist on type 'State'.
    error,
  } = state;
};

For many devs, this is going to be tricky to figure out. Both data and error can exist on State, so why am I getting errors?

The reason is that we haven't tried to discriminate the union yet! We don't know which state we're in, so the only properties available are the ones which all the members of the union share. Namely, status.

Once we've checked which branch of the union we're in, we can safely destructure state!

if (state.status === "success") {
  const { data } = state;
}

This strictness is a feature, not a bug. By ensuring you can only access data when the status equals success, you're encouraged to think of your app in terms of its states, and only access data in the states it's available.

Summary

When you start thinking of your app in terms of discriminated states, a lot of things get easier.

Instead of a big optional bag of data, you'll start understanding the connections between data and UI.

Not only that, but you'll be able to think about props in a whole new way.

What if you need to display a component in two slightly different ways? Use a discriminated union:

type ModalProps =
  | {
      variant: "with-description-and-button";
      buttonText: string;
      description: string;
      title: string;
    }
  | {
      variant: "base";
      title: string;
    };

Here, buttonText and description will only be required when the variant passed in is with-description-and-button.

Beautiful.

Matt's signature

Share this article with your friends

`any` Considered Harmful, Except For These Cases

Discover when it's appropriate to use TypeScript's any type despite its risks. Learn about legitimate cases where any is necessary.

Matt Pocock
Matt Pocock

No, TypeScript Types Don't Exist At Runtime

Learn why TypeScript's types don't exist at runtime. Discover how TypeScript compiles down to JavaScript and how it differs from other strongly-typed languages.

Matt Pocock
Matt Pocock

Deriving vs Decoupling: When NOT To Be A TypeScript Wizard

In this book teaser, we discuss deriving vs decoupling your types: when building relationships between your types or segregating them makes sense.

Matt Pocock
Matt Pocock

NoInfer: TypeScript 5.4's New Utility Type

Learn how TypeScript's new utility type, NoInfer, can improve inference behavior by controlling where types are inferred in generic functions.

Matt Pocock
Matt Pocock