Modeling with Union Types

Joël Quenneville

In a previous article, I showed how we can refactor messy Boolean code using enums (AKA union types).

Many languages turbo charge their enums, allowing them to have parameters. These are often called tagged unions or Algebraic Data Types (ADTs). Tagged unions are a killer language feature as they allow you to expressively model problem domains and avoid some of the pitfalls of relying entirely on primitives.

I’m using Elm for the examples here due to it’s first-class support of tagged unions and strict, helpful compiler.

Multi-Maybe

Unlike languages like C, JavaScript, and Ruby, Elm has no null / nil. You can explicitly tag a value as possibly not present with Maybe (often called Option in other languages like Swift). Instead of “everything could be null unless you’ve explicitly checked it”, you can work on the assumption that “everything is guaranteed present unless it’s wrapped in Maybe.

This is incredibly useful but it’s easy to overuse, particularly when coming from a language based around null. In Elm, records where a lot of fields are Maybe are a smell.

Take for example the following Booking:

type alias Booking =
  { date : Maybe Date
  , option : Maybe BookingOption
  , tickets : Maybe Ticket.Quantities
  }

A user first starts by selecting a date. A request is made to an API to fetch options for that date. The user then selects an option and another API call is made to see available tickets for that option.

Using Maybe here is problematic because it allows invalid combinations such as a selected option without a date. There are 8 (2^3) possible states that can be represented with this Booking type but only 4 are legitimate: an empty state and the three phases of selection.

We can use a tagged union to represent our 4 legitimate states:

type alias Booking =
    { date : Date
    , option : BookingOption
    , tickets : Ticket.Quantities
    }

type BookingProcess
    = NotStarted
    | DateSelected Date
    | OptionSelected Date BookingOption
    | TicketsSelected Booking

Nested Maybe

When coming from languages based around null, it’s easy to turn to Maybe whenever our data doesn’t quite fit the "shape” of our structures.

Here we have a rental that may or may not have an option selected which may or may not have a description. As it stands, our nested Maybe allows four states (2^2) that aren’t super obvious. It’s also annoying to chain Maybes in order to access the description.

type alias Rental =
  { date : Date
  , option : Maybe RentalOption
  }

type alias RentalOption =
  { name : String
  , id : String
  , description : Maybe String
  }

From a business perspective there are three states:

  1. No option selected
  2. Option selected but not described
  3. Option selected and described

We can easily turn these into code with a union type:

type RentalOption
  = NotSelected String String
  | NotDescribed String String
  | Complete String String String

The Shape of Data

Hiding behind all the maybes we’ve been looking at is a larger problem: We’re trying to force data that can have multiple “shapes” into a single shape and using Maybe whenever things don’t quite fit.

Union types are great at solving this problem because they allow us to specify multiple shapes for our data. For example, say we’re modeling a deck of cards:

type alias Card =
  { suit : String
  , value : Int
  }

This allows us to create cards like:

Card "Hearts" 2

But it also allows us to create completely invalid cards like:

Card "Starfish" 1999

We can solve this problem by creating enums that list the valid suits and values:

type Suit
  = Hearts
  | Spades
  | Diamonds
  | Clubs

type Value
  = Two
  | Three
  | Four
  | Five
  | Six
  | Seven
  | Eight
  | Nine
  | Ten
  | Jack
  | Queen
  | King
  | Ace

type alias Card =
  { suit : Suit
  , value : Value
  }

Great! Now our cards will always be valid.

So far, all cards have had the same “shape”. But what about the Joker? It doesn’t really have a value or a suit. We could use Maybe to try and force it into our existing shape. Perhaps

type alias Card =
  { suit : Maybe Suit
  , value : Maybe Value
  }

where Card Nothing Nothing is a Joker. We could get rid of one of the Maybes by making Joker either an suit type or a value but that also allows invalid configurations like a “3 of Jokers” or a “Joker of Hearts”. Trying to force a Joker into our single card shape is not going to work.

Instead, we can use a union type to allow multiple shapes:

type Card
  = StandardCard Value Suit
  | Joker