Elm Result Pipeline

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 Results 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.

Photo of Pipelines
Photo by Sigmund on Unsplash

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.

  1. For the sake of simplicity and disambiguation, we’re aliasing Dog and Error as strings here. This is not recommended practice, you should rather use opaque types instead. 

  2. 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.