4 minImpossible States, part 1

Prevent Impossible States Pt. 1 - Union Types

Use TypeScript discriminated unions to require the right fields, narrow object types, and catch unhandled cases.

For years, I was firmly in the JavaScript > TypeScript camp. Prototypal inheritance and sweet, sweet object composition gave me plenty to work with. After using TypeScript professionally, I’ve come to appreciate its expressivity, especially when it comes to describing which states a system should allow.

And so I present to you: the discriminated union. In JavaScript land, I used libraries like daggy to model tagged unions. TypeScript lets us describe those cases directly in its type system.1

Laying the groundwork

A union type is formed from two or more other types. These types are called the union’s members.

type UserId = string | number;

The UserId union has the members string and number.

That gets more interesting when we’re working with objects. Say a user must provide an email address, a phone number, or both.2 We might start by making both fields optional.

type ContactDetails = {
  email?: string;
  phoneNumber?: string;
};

const contact: ContactDetails = {};

This compiles. But with email and phoneNumber both optional, we’ve also allowed a user to provide neither.

Giving each case its own type

We have three cases to describe:

  • an email address
  • a phone number
  • both

We can write each case as an object type, then combine them into a discriminated union.

type ContactDetails =
  | { kind: 'email'; email: string }
  | { kind: 'phone'; phoneNumber: string }
  | { kind: 'both'; email: string; phoneNumber: string };

Every member has a kind field with a different string literal type: 'email', 'phone', or 'both'. This shared field is the union’s discriminant (or tag).

The name doesn’t matter; we could call it contactMethod, but kind is a strong generic choice.

The contact fields are now required in their respective cases:

  • An 'email' contact needs an email address.
  • A 'phone' contact needs a phone number.
  • A 'both' contact needs both.

We can check kind before reading the email address. TypeScript uses that check to rule out members that don’t match. This is called narrowing.

const getEmail = (contact: ContactDetails): string | undefined =>
  contact.kind !== 'phone'
    ? contact.email
    : undefined;

The condition contact.kind !== 'phone' rules out the phone-only member, leaving 'email' and 'both'.

Both require an email address, so TypeScript knows we can read contact.email in that branch. When contact.kind is 'phone', the function returns undefined.

Enforcing the requirements

With the new ContactDetails definition, the empty object from our first example is a type error:

const contact: ContactDetails = {};
// Error: Type '{}' is not assignable to type 'ContactDetails'.

We now have to choose one of the three cases and provide the fields it requires.

Handling every case

We can also handle each member separately. For example, a user profile could display a contact summary:

const describeContact = (contact: ContactDetails): string => {
  switch (contact.kind) {
    case 'email':
      return `Email: ${contact.email}`;
    case 'phone':
      return `Phone: ${contact.phoneNumber}`;
    case 'both':
      return `Email: ${contact.email}, phone: ${contact.phoneNumber}`;
    default: {
      const unreachable: never = contact;
      return unreachable;
    }
  }
};

Each branch handles one member of the union. After all three, the assignment to never checks that no case remains. This is an exhaustiveness check: add another member without handling it here, and TypeScript reports an error at that assignment.

What this does not solve yet

We’ve described which fields each case requires, but we haven’t checked whether an email address or phone number is valid. An empty string still satisfies string. Data coming from a form or an API still needs runtime validation.

We also haven’t defined which changes are allowed. A timer’s idle, running, and paused states may all be valid, but that doesn’t tell us whether to accept a pause event while it’s idle. A state machine lets us make those transition rules explicit.

The lesson

Two optional fields allowed a case we didn’t want. The discriminated union makes our three allowed cases explicit, and TypeScript can check how we create and handle them.

Footnotes

  1. I tinkered with ReasonML a few years back. Dave Aitken’s article on ReasonML state machines uses variants and Imandra to explore a simplified model inspired by the FaceTime bug. ↩

  2. This example adapts the three-case structure in busypeoples’ Making Impossible States Impossible in ReasonML. The original uses email and social login; here, we’re using email and phone contact details. ↩