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 Maybe
s
so every step we need nested map
s. 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.