Problem Solving with Maybe

Joël Quenneville

Maybe has a tendency to take over your codebase. This property is sometimes described as being viral. Uncertainty begets uncertainty. This sort of defeats the goal of using Maybe in the first place: give you confidence in the presence of most values.

By slightly changing our approach to solving problems involving uncertainty, we can contain Maybe to those parts of our code that are truly optional.

The problem

Consider trying to get the uppercased first line of a friend’s address when you’re uncertain whether the friend is present or not:

optionalFormattedFriendAddress : Maybe Friend -> Maybe String
optionalFormattedFriendAddress maybeFriend =
  let
    maybeAddress = case maybeFriend of
      Just friend -> Just friend.address
      Nothing -> Nothing

    maybeLine1 = case maybeAddress of
      Just address -> Just address.line1
      Nothing -> Nothing

  in
    case maybeLine1 of
      Just line1 -> Just (String.toUpper line1)
      Nothing -> Nothing

That’s annoyingly complex and clunky 😰

Only the friend is optional but we’re forced to make presence checks for each operation in the chain. We could refactor the case statements into calls to map but that’s still making multiple presence checks, albeit in a much prettier fashion.

What we need is a different approach to solving the problem.

Extracting functions

All this brings me to Rule 1 when working with Maybe:

Separate code that checks for presence from code that calculates values

Imagine you were in a perfect world where all the values were present. Write that function.

Of course you live in the real world so now you need to write another function that combines your “perfect” function with the Maybe helpers.

Looking at the previous example, in a perfect world, the friend is always present. Let’s write that function first.

formattedFriendAddress : Friend -> String
formattedFriendAddress friend =
  String.toUpper friend.address.line1

Then write a separate function that handles the presence check:

optionalFormattedFriendAddress : Maybe Friend -> Maybe String
optionalFormattedFriendAddress maybeFriend =
  maybeFriend
    |> Maybe.map formattedFriendAddress

Multiple independent optional values

So far we’ve only been dealing with a single optional value. What happens when we need to deal with multiple values that might not be present? Let’s say we have two users who may or may not be present and we want to find what their shared friends are?

Rule 1 still applies. We start by writing a “perfect world” function that assumes all values are present:

sharedFriends : User -> User -> Set Friend
sharedFriends user1 user2 =
  Set.intersect (Set.fromList user1.friends) (Set.fromList user2.friends)

Now we can write a separate function that checks for presence. Because there are two Maybe values, we use Maybe.map2.

optionallySharedFriends : Maybe User -> Maybe User -> Maybe (Set Friend)
optionallySharedFriends maybeUser1 maybeUser2 =
  Maybe.map2 sharedFriends maybeUser1 maybeUser2

Adding in some uncertainty

Sometimes, even “perfect world” functions encounter uncertainty. Rule 2 gives us some clarity:

The extracted function can return Maybe but it may not accept Maybe as any of its arguments.

Say we wanted to find an optional user’s most popular friend. By Rule 1, we start by ignoring that the user might be optional and we assume they will be present:

mostPopularFriend : User -> Maybe Friend
mostPopularFriend user =
  user.friends
    |> List.sortBy popularity
    |> List.reverse
    |> List.head

Event though we’re following the “perfect world” rule, we’re forced return a Maybe here because the list of friends might be empty. That’s OK by Rule 2 though.

Putting it all together, you’ll notice we need to use andThen instead of map. This is almost always the case when invoking Rule 2:

maybeMostPopularFriend : Maybe User -> Maybe Friend
maybeMostPopularFriend maybeUser =
  maybeUser
    |> Maybe.andThen mostPopularFriend

Lots of uncertainty

Sometimes, you’ll run into a situation where need to invoke Rule 2 multiple times. For example, you want to know where the most popular friend lived the longest. The “perfect world” function starts by assuming that the friend is present but there’s no guarantee they’ve shared any addresses:

longestResidence : Friend -> Maybe Address
longestResidence friend =
  friend.addresses
    |> List.sortBy lengthOfStay
    |> List.reverse
    |> List.head

Unlike a chain of map functions, we can’t compose functions that invoked Rule 2. Instead, we chain them together with andThen:

maybeMostPopularFriendLongestResidence : Maybe User -> Maybe Address
maybeMostPopularFriendLongestResidence maybeUser =
  maybeUser
    |> Maybe.andThen mostPopularFriend
    |> Maybe.andThen longestResidence

Avoiding Maybe altogether

This whole discussion on avoiding the need for presence checks has skipped an obvious solution: avoid Maybe altogether. This is Rule 0:

Actively try to model your data structures to avoid Maybe

So is Maybe a bad thing? Should you start writing that Maybe considered harmful blog post? No.

Maybe tends to be overused, especially when coming from languages that mostly deal with uncertainty with null. Maybe doesn’t correlate one-to-one with uses of null and there are often better ways to model your data.

Optional lists

Take this model:

type alias Model =
  { numbers : Maybe (List Int)
  }

List already has an empty state: []. Does Just [] mean something different than Nothing here? If it doesn’t, we can can collapse this down to a List without the wrapping Maybe.

Explore the idea more in this article on alternatives to Maybe (List a).

Optional shapes

A common mistake is to try to model data that can have several shapes by shoehorning the data into a record with a bunch of Maybe fields to handle differences. For example, modeling a standard deck of cards:

type alias Card =
  { suit : Maybe Suit
  , rank : Maybe Rank
  }

The Joker is Card Nothing Nothing. We can do better by modeling with union types:

type Card
  = Regular Suit Rank
  | Joker

Conclusion

Maybe is viral and can quickly overrun your codebase. By taking a step back and considering how you deal with uncertainty in your code, you can avoid a lot of pain.

More concretely, the guidelines we’ve looked at lead to code that’s mostly free of presence checks:

Rule 0: Actively try to model your data structures to avoid Maybe.

Rule 1: Separate code that checks for presence from code that calculates values.

Rule 2: The extracted function can return Maybe but it may not accept Maybe as any of its arguments.