DEV Community

Cover image for Writing Industrial-Strength TypeScript with Discriminated Unions
Greg Reimer
Greg Reimer

Posted on

Writing Industrial-Strength TypeScript with Discriminated Unions

By now you've heard the tagline: Make impossible states impossible.

I absorbed the concept a while back, and it's hard to overstate how profoundly it influenced my approach data modeling. In essence, we can rule out bad states in our app by choosing the right data model. Others have covered this from various angles, for example this great React-themed overview by Kent C. Dodds, or the famous Elm talk by Richard Feldman. In fact, the idea has its roots in the functional world.

Now as much as I love Elm, I also write a lot of TypeScript, and I like to bring functional ideas over whenever I can. So what is the TypeScript angle here? How does it make impossible states impossible?

Read on.

Simple unions

Expanding on Kent's article above, we might declare a Status type like this:

type Status = {
  success: boolean;
  warning: boolean;
  danger: boolean;
};

Our intent is that exactly one of these booleans would be true at any time. But what's stopping none of them being true? What happens if two or more were true? We have an implicit rule, but nothing—beyond our wits and good intentions—will actually enforce it. Alternatively, we could use a union type:

type Status = {
  type: 'success' | 'warning' | 'danger';
};

This declaration means exactly one of the three values has to be the case at any time. Nothing else is permitted, and try as we might, we can't break that contract without introducing a type error. Now our implicit rule is made explicit—and better yet, it's enforced.

A more complex scenario

Okay, so you might be thinking the above is useful, but maybe a bit simplistic for real-world apps. These are just string literals. What about complicated data structures with lots of fields? Here's where we go industrial-strength.

Suppose we have a music lookup app. The UI has two selectors, one for genre, another for artist. Here's our data model:

type Selection = {

  // user's chosen genre
  // null if none selected
  genre: string | null;

  // list of artists within genre
  // null if no genre selected
  artists: string[] | null;

  // user's chosen artist
  // null if none selected
  artist: string | null;
};

Finally, here's an event handler—onGenreChange—which triggers when the user selects or de-selects the genre. Can you spot the bugs?

// assume an instance (selection) and a
// setter (setSelection) are in scope here
function onGenreChange(genre: string | null) {
  setSelection({ ...selection, genre });
}

Oops! When the user selected a genre, we forgot to populate the artists array. When they de-selected the genre, we forgot to set both artists and artist back to null. These would almost certainly be bugs.

Re-thinking the model

Let's start out by simply declaring what the valid states actually are, individually. We'll call these our cases.

// when nothing is selected
type EmptySelection = {
  is: 'empty';
};

// when only genre is selected
type GenreSelection = {
  is: 'genre';
  genre: string;
  artists: string[];
}

// when both genre and
// artist are selected
type ArtistSelection = {
  is: 'artist';
  genre: string;
  artists: string[];
  artist: string;
};

Now, we declare a union type over these cases:

type Selection
  = EmptySelection
  | GenreSelection
  | ArtistSelection;

Let's break this down.

First: notice this is a union of objects, not strings as we saw before. Thus our selection object—as a whole—must exactly match one of the cases in the union. Furthermore, cases are distinct, and must be treated distinctly. We can't intermingle them without causing a type error.

Second: you may have noticed all these objects have an is property, each with a different label. Why? That makes it a discriminated union. We can now differentiate among the cases by looking at just that property, which we call the discriminant.

Finally: Based on the is discriminant, TypeScript will enforce the remaining properties on the case! This lets us model variations on arbitrarily complex objects, while tagging each variant with a simple, memorable label.

Discriminated unions in action

The examples below use our new discriminated union type. Look at these examples and note why each is either okay or a type error:

// Type error!
// `genre` field not allowed
const selection: Selection = {
  is: 'empty',
  genre: 'Showtunes',
};

// Type error!
// haven't narrowed to a case
const genre = selection.genre
  || 'no genre selected';

// Okay!
const selection: Selection = {
  is: 'empty',
};

// Type error!
// missing `artists` field
const selection: Selection = {
  is: 'genre',
  genre: 'Afro-Beat',
};

// Okay!
const genre = selection.is === 'genre'
  ? selection.genre
  : 'no genre selected';

As you can see, we're only allowed to read the fields of a selection if we've first narrowed to a single case by checking the discriminant in a conditional. We're only allowed to create a selection if the object exactly matches the case tagged in the discriminant.

Seems like a lot of rules to follow, right? But as we learned, these rules were already implicit in our program. When we broke them, we got bugs. By choosing a better data model, we've made it impossible to break them in the first place.

Fixing our event handler

Let's revise our event handler using the improved model. When the user updates the genre, we pass Selection objects to our setSelection callback. TypeScript looks at the discriminant we provide and enforces the remaining fields.

function onSelect(genre: string | null) {
  if (genre === null) {
    setSelection({ is: 'empty' });
  } else {
    const artists = getArtistsByGenre(genre);
    setSelection({ is: 'genre', genre, artists });
  }
}

Also—and maybe this is subjective—but I think this code is easy to follow, because each case is tagged explicitly. Easier for TypeScript to enforce; easier for me to read.

Diving Deeper

Now that we've outlined the basic idea, let's dive a little deeper. We'll look at refactoring, show additional examples, and fill in some details.

Naming the discriminant

You might be wondering, is there anything magical about the is property? In one sense, no, it's just a property on an object; you can name it anything. Personally, I like to name it "is", because it's short and easy to type, and it makes conditionals read like sentences:

if (selection.is === 'empty') { ... }
// "If selection is empty..."

But really you can name it anything you want, like "mode" or "case" or "tag" or "variant" or "disambiguator" or "switchKey". In order to make TypeScript treat it as a discriminant, the name just needs to be shared, and the values distinct, across all cases.

Refactoring

In our music app, we can't pre-load the entire artist database in client memory. More realistically, we'll need to query it lazily after the user selects a genre. But then what should our state look like between selection of the genre and loading of the artists list? And what if the query fails?

This highlights another nice feature of discriminated unions, which is their refactorability. Since everything is now locked-in to the type system, we can confidently add new cases to the union, knowing TypeScript will tell us if we've broken anything:

// ...other cases unchanged...

type GenreSelectionLoading = {
  is: 'loading';
  genre: string;
}

type GenreSelectionFailed = {
  is: 'failed';
  genre: string;
  reason: string;
};

type Selection
  = EmptySelection
  | GenreSelectionLoading
  | GenreSelectionFailed
  | GenreSelection
  | ArtistSelection;

We simply set these states as needed in our event handler, and let the UI render a spinner, error message, or retry button as appropriate on those states:

async onGenreChange(genre: string | null) {
  if (genre === null) {
    setSelection({ is: 'empty' });
  } else {
    setSelection({ is: 'loading', genre });
    try {
      const artists = await fetchArtistsByGenre(genre);
      setSelection({ is: 'genre', genre, artists });
    } catch(err) {
      const reason = err.message;
      setSelection({ is: 'failed', genre, reason });
    }
  }
}

Boolean discriminants

Since booleans only have two possible values, one weird trick I like to use when dealing with binary states is to use a boolean for the discriminant instead of a string.

type Result = {
  ok: true;
  value: string;
} | {
  ok: false;
  error: string;
};

Why? It simplifies conditional logic since I can omit the equality testing. Plus it plays nicely with the if/else idiom:

const result: Result = somethingThatGetsAResult();

if (result.ok) {
  // result.value -- ok
  // result.error -- error
} else {
  // result.value -- error
  // result.error -- ok
}

Adding generics

Discriminated unions also play nicely with generics. Adding generics to the above, I now have a general-purpose way to do error handling without resorting to try/catch.

type Result<T> = {
  ok: true;
  value: T;
} | {
  ok: false;
  error: string;
};

For example, suppose I have a parseDate() function which instead of throwing on invalid input, returns a Result<Date>:

const parsed: Result<Date>
  = parseDate(userInputString);

if (parsed.ok) {
  setDate(parsed.date);
} else {
  setError(parsed.error);
}

Wrapping up

Discriminated unions are known by other names in other languages, such as algabraic data types, tagged unions, or sum types. For example, they're central to the Elm architecture and type system. I've been using Elm for years, and more recently learned TypeScript, and discriminated unions have become one of my main data-modeling tools for prevention of impossible states.

I hope this writeup helps further popularize this relatively under-utilized technique in the TypeScript world, so others can benefit as well. Thanks for reading!

More info

About myself

My name is Greg, and I've been building websites in one form or another for a while now. You can also find me on:

Top comments (0)