One of the best features of pure functional programming languages like Elm is their ability to handle and deal with uncertainty. The absence of null
and exceptions forces developers to state explicitely what should happen when expectations aren’t met, which combined with a good compiler and a strong static type system makes the code super descriptive and rock solid.
A classic example is when you query a data structure which can be empty:
> dogs = [ "Lassie", "Scooby-Doo" ]
["Lassie","Scooby-Doo"] : List String
> dogs |> List.head
Just "Lassie" : Maybe String
> dogs = []
[] : List a
> dogs |> List.head
Nothing : Maybe a
Meaning you may handle Maybe
, Maybe.map
, Maybe.andThen
, Maybe.withDefault
and so on if you want to ensure you handle the uncertainty of actually holding a value:
> ["Lassie"]
|> List.head
|> Maybe.map String.toUpper
|> Maybe.withDefault "oh no ;("
"LASSIE" : String
> []
|> List.head
|> Maybe.map String.toUpper
|> Maybe.withDefault "oh no ;("
"oh no ;(" : String
Same goes with Result
, which is basically a Maybe
with an alternate value — typically an error — attached:
> findDog name =
List.filter ((==) name)
>> List.head
>> Result.fromMaybe ("oh no, can't find " ++ name)
<function> : String -> List String -> Result String String
> ["Lassie", "Scooby-Doo"]
|> findDog "Scooby-Doo"
Ok "Scooby-Doo" : Result String String
> ["Lassie", "Scooby-Doo"]
|> findDog "Rintintin"
Err ("oh no, can't find Rintintin") : Result String String
So really, Result
is super useful. Now it’s so useful that sometimes, you want to use it a lot, eg. in records1:
type alias Dog = String
type alias Error = String
type alias FavoriteDogs =
{ dogSlot1 : Result Error Dog
, dogSlot2 : Result Error Dog
, dogSlot3 : Result Error Dog
, dogSlot4 : Result Error Dog
, dogSlot5 : Result Error Dog
, dogSlot6 : Result Error Dog
}
Hmm wait, imagine you’re only interested in a FavoriteDogs
record when all six available slots are fulfilled. Checking for this is going to be painful:
showDogs : FavoriteDogs -> Html msg
showDogs favorites =
case favorites.dogSlot1 of
Ok dog1 ->
case favorites.dogSlot2 of
Ok dog2 ->
case dogSlot2 of
Ok dog2 ->
-- To be continued… At some point
-- we can use dog1, dog2 -> dog6
Err error ->
Html.text error
Err error ->
Html.text error
Err error ->
Html.text error
Luckily we have the Result.map
familly of functions:
firstTwoDogs : FavoriteDogs -> Result Error Dog
firstTwoDogs { dogSlot1, dogSlot2 } =
Result.map2
(\dog1 dog2 -> dog1 ++ " and " ++ dog2)
dogSlot1
dogSlot2
firstThreeDogs : FavoriteDogs -> Result Error Dog
firstThreeDogs { dogSlot1, dogSlot2, dogSlot3 } =
Result.map3
(\dog1 dog2 dog3 ->
String.join ", " [ dog1, dog2, dog3 ]
)
dogSlot1
dogSlot2
dogSlot3
But wait, we don’t have Result.map6
! The core implementation of Result.map5
is pretty verbose already, I can understand why they avoided going further haha. But more annoyingly, that means you don’t have a convenient helper for mapping more than 5 Result
s at once, for example to build a record having 6.
Also, ideally we’d rather want to deal with a data structure with direct access, to avoid messing around too much with the Result
api:
type alias FavoriteDogs =
{ dogSlot1 : Dog
, dogSlot2 : Dog
, dogSlot3 : Dog
, dogSlot4 : Dog
, dogSlot5 : Dog
, dogSlot6 : Dog
}
Pipelining to the rescue!
Here’s a convenient helper I use to build a record using the pipeline builder pattern; it’s often known in functional languages as apply
, but I like resolve
:
resolve : Result x a -> Result x (a -> b) -> Result x b
resolve result =
Result.andThen (\partial -> Result.map partial result)
Which can be shortened even further — though becoming less explicit2 — with:
resolve : Result x a -> Result x (a -> b) -> Result x b
resolve =
Result.map2 (|>)
This little helper allows creating a fully-qualified FavoriteDogs
record this way:
build : Result Error FavoriteDogs
build =
Ok FavoriteDogs
|> resolve (findDog "Lassie" dogs)
|> resolve (findDog "Toto" dogs)
|> resolve (findDog "Trakr" dogs)
|> resolve (findDog "Laïka" dogs)
|> resolve (findDog "Balto" dogs)
|> resolve (findDog "Jofi" dogs)
You might have already seen this pattern used in the popular elm-json-decode-pipeline package.
The cool thing with this approach is that if a single result fails, the whole operation fails with the error of the first failure encountered during the build process:
dogs : List Dog
dogs =
[ "Lassie", "Toto", "Trakr", "Laïka", "Balto", "Jofi" ]
findDog : Dog -> List Dog -> Result Error Dog
findDog name =
List.filter ((==) name)
>> List.head
>> Result.fromMaybe ("oh no, can't find " ++ name)
type alias FavoriteDogs =
{ dogSlot1 : Dog
, dogSlot2 : Dog
, dogSlot3 : Dog
, dogSlot4 : Dog
, dogSlot5 : Dog
, dogSlot6 : Dog
}
buildOk : Result Error FavoriteDogs
buildOk =
Ok FavoriteDogs
|> resolve (findDog "Lassie" dogs)
|> resolve (findDog "Toto" dogs)
|> resolve (findDog "Trakr" dogs)
|> resolve (findDog "Laïka" dogs)
|> resolve (findDog "Balto" dogs)
|> resolve (findDog "Jofi" dogs)
-- Gives:
-- Ok
-- { dogSlot1 = "Lassie"
-- , dogSlot2 = "Toto"
-- , dogSlot3 = "Trakr"
-- , dogSlot4 = "Laïka"
-- , dogSlot5 = "Balto"
-- , dogSlot6 = "Jofi"
-- }
buildErr : Result Error FavoriteDogs
buildErr =
Ok FavoriteDogs
|> resolve (findDog "Lassie" dogs)
|> resolve (findDog "Toto" dogs)
|> resolve (findDog "Garfield" dogs) -- woops!
|> resolve (findDog "Laïka" dogs)
|> resolve (findDog "Balto" dogs)
|> resolve (findDog "Jofi" dogs)
-- Gives:
-- Err ("oh no, can't find Garfield")
That’s all folks, hope it’s useful.
Disclaimer
This post has been written in one hour tops more than that with all the feeddback received. This is an attempt at forcing myself to write again on this blog, just don’t judge me too harsh!
Thanks
Thanks to Alexis, Ethan, Mathieu, Mathieu and Rémy for their precious feedback.
Update
Thanks to elm-search, I could find that the elm-result-extra package provides andMap
, which allows exactly the same thing as my resolve
helper.
-
For the sake of simplicity and disambiguation, we’re aliasing
Dog
andError
as strings here. This is not recommended practice, you should rather use opaque types instead. ↩ -
The type signature and implementation of
resolve
might be hard to grasp for the non-seasoned Elm developer; this section of the Elm Guide may be a good read. ↩