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 acceptMaybe
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 acceptMaybe
as any of its arguments.