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 Maybe
s 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:
- No option selected
- Option selected but not described
- 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 Maybe
s
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