The Mechanics of Maybe

Joël Quenneville

Our world is full of uncertainty. This uncertainty bleeds into our programs. A common way of dealing with this is null/nil. Unfortunately, this leads to even more uncertainty because this design means any value in our system could be null unless we’ve explicitly checked it’s presence. Constantly checking the presence of every value is a lot of work so we tend to only check the riskiest places and then have to deal with runtime null exceptions in the rest of our code.

Maybe

Elm and many other languages use a different approach to dealing with uncertainty: Maybe.

In Elm, all values are guaranteed to be present except for those wrapped in a Maybe. This is a critical distinction. You can now be confident in most of your code and the compiler will force you to make presence-checks in places where values are optional.

Case statements

The most basic way of dealing with a Maybe is via a case statement:

case someOptionalValue of
  Just value -> -- do something if present
  Nothing -> -- do something if NOT present

This is your standard presence-check.

Unfortunately, this can quickly devolve into a nightmare of nested case statements. For example, say you have a list of users and you want to uppercase the name of the first friend of the first user. You’d end up with this monstrosity:

case List.head users of
  Just user ->
    case List.head user.friends of
      Just friend ->
        Just (String.toUpper friend.name)

      Nothing ->
        Nothing

  Nothing ->
    Nothing

Luckily, the language can help us out with some helper functions.

Helper functions - unwrapping

A few checks are so common that there are convenient helper functions for them so you don’t have to write manual case statements.

For example, when trying to unwrap an optional value you need to handle the case where it isn’t present. This is typically done by providing some default value.

case optionalNumber of
  Just number -> number
  Nothing -> 0 -- return the default 0 when we didn't have a value

With the helper function Maybe.withDefault it becomes

Maybe.withDefault 0 optionalNumber

Helper functions - unwrapping and re-wrapping

Often there is no sensible default value so you just want to re-wrap in Maybe after doing a calculation. Isolating the first step of our big case statement (getting the friends of the first user) we might say:

case List.head users of
  Just user -> Just user.friends
  Nothing -> Nothing

This keeps propagating the Maybe forward down the chain. It’s very common to want to say “call this function if the value is present and return a new Maybe” so there’s a helper function we can use here: Maybe.map:

Maybe.map .friends (List.head users)

Helper functions - nested maybes

Sometimes, the function you call when the value is present already returns a Maybe so there’s no need to re-wrap it in Just. Isolating the second step of our big case statement (getting the first friend) might look like:

case maybeFriends of
  Just friends -> List.head friends -- no Just here
  Nothing -> Nothing

While it might look like we can use map here, there’s one crucial distinction: map re-wraps your successful computation with Just. That means using map here would result in a doubly-nested maybe which is not what we wanted.

Instead, we can use the Maybe.andThen function:

maybeFriends
  |> Maybe.andThen List.head

A more extreme case

Looking at a more extreme case, say we wanted to get at the head of the inner list [[[[1, 2]]]] using repeated uses of List.head:

[[[[1, 2]]]]
  |> List.head -- Just [[[1, 2]]]
  |> Maybe.map List.head -- Just (Just [[1,2]])
  |> Maybe.map (Maybe.map List.head) -- Just (Just (Just [1,2]))
  |> Maybe.map (Maybe.map (Maybe.map (List.head)) -- Just (Just (Just (Just 1)))

We got the head of the inner list but now it’s deeply nested inside of Maybes so every step we need nested maps. This will not scale 😰

Consider the andThen-based implementation instead

[[[[1, 2]]]]
  |> List.head -- Just [[[1, 2]]]
  |> Maybe.andThen List.head -- Just [[1,2]]
  |> Maybe.andThen List.head -- Just [1,2]
  |> Maybe.andThen List.head -- Just 1

Notice that each step is only a single Just deep, no matter how many times we chain.

map vs andThen

So when do you want to use map vs andThen? A quick trick is to look at the return type of the function you’re passing in:

something -> Maybe somethingElse -- use andThen
something -> somethingElse -- Use map

In our example above, .friends returns a list so we can use it with map while List.head returns a maybe so we need to use andThen.

Nested case statements

When first using Maybe, you’ll often end with large, nested case statements. A closer look usually reveals that they conform to one of the three patterns shown earlier.

Remember that nested case statement we started with?

case List.head users of
  Just user ->
    case List.head user.friends of
      Just friend ->
        Just (String.toUpper friend.name)

      Nothing ->
        Nothing

  Nothing ->
    Nothing

Nested branching logic, error handling, and duplicated cases make it very difficult to follow the logic here. 😁

All those trailing Nothing -> Nothing cases are a giveaway that we can clean this up using some of the helper functions. Combine them all into a pipeline and we get:

users
  |> List.head
  |> Maybe.map .friends
  |> Maybe.andThen List.head
  |> Maybe.map .name
  |> Maybe.map String.toUpper

This is much nicer to read but is still equivalent to the case statement. In part 2, we’ll look at problem solving with Maybe and how changing the structure of our logic can help push uncertainty to the edges of our system.