Exploring the Pipeline Pattern in Elm

Brendan Benson

As an Elm developer, you’re probably familiar with the forward function application operator (|>). And you may have used the NoRedInk/elm-decode-pipeline package to create JSON decoders like so:

userDecoder : Decoder User
userDecoder =
  decode User
    |> required "id" int
    |> required "email" (nullable string)
    |> optional "name" string "N/A"

But have you really taken the time to sit down and think about what the |> is really doing in this case?

In this post, I’ll show you how to use the |> operator to create pipelines and to simplify your existing Elm code.

You’ll end up with a technique to simplify the code in your update function. Here’s a teaser:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Increment ->
      (model, Cmd.none) 
        |> updateCounter 1
        |> recordAnalyticsEvent "counterIncremented"
        |> addSuccessMessage "You incremented the counter!"

First, let’s quickly review the forward function application operator. It takes the value on the left and applies it to the function on the right (follow along in elm-repl if you wish):

import String exposing (..)

"Hello" |> isEmpty -- False

"" |> isEmpty -- True

Pretty simple, right? Next, let’s follow this pattern to manipulate a string:

"Hello everyone" |> left 5 |> toLower |> append "I say " -- "I say hello"

Now we’re passing the value returned from each function to the next, starting with the base value of "Hello everyone" and ending with the final value of "I say hello".

Let’s examine what enables us to do this by looking at the type signatures of each of the functions we’re using:

left : Int -> String -> String

toLower : String -> String

append : String -> String -> String

While all three type signatures differ, they have one important thing in common: they all end with String -> String.

To generalize this concept, we can say that if we have a function that returns a type a, then we can create pipeline functions for it where each pipeline function type signature ends with a -> a.

Let’s take this knowledge and apply it to the update function in The Elm Architecture. We’ll start by examining its signature:

update : Msg -> Model -> (Model, Cmd Msg)

Since update returns (Model, Cmd Msg) we can create pipelines for it with functions that end with (Model, Cmd Msg) -> (Model, Cmd Msg).

Here’s a (really simple) example:

incrementCounter : (Model, Cmd Msg) -> (Model, Cmd Msg)
incrementCounter (model, cmd) =
  ({ model | counter = model.counter + 1 }, cmd) 

And here’s how to use it:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Increment ->
      (model, Cmd.none) |> incrementCounter

Unfortunately, incrementCounter is very brittle, as it can only do one thing: increment the counter field of the Model by exactly one. Since our app will also feature a decrement button, let’s change incrementCounter to take a parameter and rename it:

updateCounter : Int -> (Model, Cmd Msg) -> (Model, Cmd Msg)
updateCounter delta (model, cmd) =
  ({ model | counter = model.counter + delta }, cmd) 

Now we can add the Decrement action to our update function:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Increment ->
      (model, Cmd.none) |> updateCounter 1
    Decrement ->
      (model, Cmd.none) |> updateCounter -1

While this example is quite trivial, you can apply it to a variety of use cases to create reusable building blocks.

Let’s say you have an app that calls an external service to record analytics events and presents the user with a list of disappearing success and error messages. You might write something like:

recordAnalyticsEvent : String -> (Model, Cmd Msg) -> (Model, Cmd Msg)
recordAnalyticsEvent eventName (model, cmd) =
  let
    analyticsCmd = -- A bunch of code to call the analytics server
  in
    (model, Cmd.batch [cmd, analyticsCmd])
    
addSuccessMessage : String -> ( Model, Cmd Msg ) -> ( Model, Cmd Msg )
addSuccessMessage m ( model, cmd ) =
  let
    removeUserMessageCmd = -- Cmd to remove the message after 5 seconds
  in
    ({ model | userMessages = SuccessMessage m :: model.userMessages } 
      , Cmd.batch [ cmd, removeUserMessageCmd ])

And then in your update function:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    Increment ->
      (model, Cmd.none) 
        |> updateCounter 1
        |> recordAnalyticsEvent "counterIncremented"
        |> addSuccessMessage "You incremented the counter!"
    Decrement ->
      (model, Cmd.none) 
        |> updateCounter -1
        |> recordAnalyticsEvent "counterDecremented"
        |> addSuccessMessage "You decremented the counter!"
    ...

Great! Now we have a bunch of reusable functions that can be sequenced to build complex update logic in our Elm application! Any time we need to record another analytics event or add a success message, we simply just pipe our update return value to the functions we created above.

Bonus Round

Let’s explore how we can create pipelines with the Maybe type in Elm using our string manipulation example above:

Just "Hello everyone" |> map (left 5) |> map (toLower) |> map (append "I say ")

Notice that the only difference is the map call before each function call. I’ll leave the reasoning to the reader - but a good place to start is the Elm documentation for Maybe.

In Haskell, Maybe would implement the Data.Functor typeclass, and map would actually be fmap. Elm doesn’t use typeclasses (in order to keep things a little more simple), but if you’re interested in exploring the concept further, check out the Haskell Functor documentation.

comments powered by Disqus