Running Out of Maps

Joël Quenneville

Many Elm packages provide map2, map3, map4, etc functions. No matter how many of these the package author has provided, inevitably someone will end up needing a mapN larger than those included in the package. Perhaps that “someone” is you. How do you deal with a situation where you run out of maps?

The problem

You have a record that describes a user. It has 5 fields.

type alias User =
  { name : String
  , age : Int
  , address : String
  , email : String
  , role : Role
  }

type Role = Admin | Regular

The data you are using to construct the user comes from an uncertain source so any individual piece of data could be absent. If all the pieces of data are present, you want to construct a User with them, otherwise you want to get a Nothing back.

Since the User constructor is a 5-argument function and you have 5 maybe pieces of data, you can use the Maybe.map5 function. No problem!

Maybe.map5 User
  (Just "Alice")
  (Just 42)
  (Just "41 Winter Street")
  (Just "alice@example.com")
  (Just Regular)

-- Just
--   { name = "Alice"
--   , age = 42
--   , address = "41 Winter Street"
--   , email = "alice
--   , role = Regular
--   }

Now a new requirement has come in. You need to store the user’s language preference as a 6th field on the record. That should be pretty straightforward. The new User constructor will be a 6-argument function and now well have 6 maybe pieces of data. Just swap out the map5 for a map6.

But wait! The Maybe library only goes up to map5. There is no map6!

One-liner to rule them all

The solution to all your mapping problems is this little helper. It is the key to being able to map an arbitrarily large number of optional values.

andMap = Maybe.map2 (|>)

Solving the problem, pipeline-style

The andMap function allows us to take the same pieces as an equivalent mapN functions (here a 6-argument function and 6 pieces of optional data) but organizes them slightly differently using a pipeline style. It ends up looking like:

Just User
  |> andMap (Just "Alice")
  |> andMap (Just 42)
  |> andMap (Just "41 Winter Street")
  |> andMap (Just "alice@example.com")
  |> andMap (Just Regular)
  |> andMap (Just "en-us")

You start by wrapping your function in Just, then pipe to andMap for each of your pieces of optional data. Note that order is important here. The optional values need to be piped to in the same order as the arguments to User.

This can be extended to any arbitrary size to adding more pipes to the chain. No more limitations!

Other types

Mapping over multiple values is a common pattern for many types, not just Maybe. You can use this pipeline pattern for them as well. This technique works for any type that defines a map2 and some way to “wrap” the initial function. This works even if the type’s internals are private. Running out of map functions for a random generator? No problem!

andMap = Random.map2 (|>)

userGenerator : Random.Generator User
userGenerator =
  Random.constant User
    |> andMap nameGenerator
    |> andMap ageGenerator
    |> andMap addressGenerator
    |> andMap emailGenerator
    |> andMap roleGenerator
    |> andMap languageGenerator

Need to decode a large JSON object? Pipeline decoding is a popular solution that builds on andMap. Any time you’re running out of map functions, andMap is there to save the day!

If you’re just looking for a fix to the problem of running out of map functions, feel free to stop reading here. You’ve got a solution! If you’re curious about why this works, get ready for a deep dive to see what’s happening behind the scenes.


Under the hood

When we introduced andMap earlier, it was as a terse one-liner. What’s actually happening there? Here’s what a long-form version might look like:

andMap : Maybe a -> Maybe (a -> b) -> Maybe b
andMap maybeItem maybeFunction =
  case (maybeItem, maybeFunction) of
    (Just item, Just function) ->
      Just (function item)

    _ ->
      Nothing

This function takes two arguments: a value and a function, both wrapped in Maybes. If both are present it unwraps them, applies the function to the argument, and re-wraps the result in Just. In effect, this allows us to pass an argument to a function, even if both are wrapped in Maybe.

How it works

This andMap function we’ve built allows us to apply a function to a value where both are inside a Maybe (that is to say we’re uncertain whether they are present). Let’s try it out!

andMap (Just 3) (Just \x -> x + 1)
-- returns `Just 4`

We could also pipe in the arguments like. On the first line we have our incrementing function wrapped in a Maybe.

Just (\x -> x + 1)    -- Maybe (Int -> Int)
  |> andMap (Just 3)  -- Maybe Int
 -- returns `Just 4`

We can expand this to work with multi-argument functions by taking advantage of Elm’s partial application. If we call andMap with a 2-argument function, we will get back a Maybe 1-argument function. We’ve seen above that we can apply an argument to that kind of function by piping to andMap.

Just add              -- Maybe (Int -> (Int -> Int))
  |> andMap (Just 3)  -- Maybe (Int -> Int)
  |> andMap (Just 2)  -- Maybe Int

Each pipe to andMap applies an argument to the function. If the function needs more arguments it will return a new function. We can keep chaining andMap until all of the arguments have been applies and we are left with the final value. That’s the magic behind the pipeline style!

Where does the one-liner come from?

Now you know how andMap works but how does it relate to the one-liner shown at the beginning of the article?

Here is the expanded version again:

andMap : Maybe a -> Maybe (a -> b) -> Maybe b
andMap maybeItem maybeFunction =
  case (maybeItem, maybeFunction) of
    (Just item, Just function) ->
      Just (function item)

    _ ->
      Nothing

If this unwrap two maybes, do a thing, re-wrap pattern looks familiar, it’s because that’s what the various map functions do. We can replace that case expression with a Maybe.map2. The code still works the same.

andMap : Maybe a -> Maybe (a -> b) -> Maybe b
andMap maybeItem maybeFunction =
  Maybe.map2 (\item function -> function item)
    maybeItem
    maybeFunction

We can reduce this even further! That lambda applies a function to an argument. It acts just like the forwards pipe |> operator.

(\item function -> function item)

-- same as
item |> function

This means we can reduce our function to:

andMap : Maybe a -> Maybe (a -> b) -> Maybe b
andMap maybeItem maybeFunction =
  Maybe.map2 (|>)
    maybeItem
    maybeFunction

Eliminate the arguments with point-free style and drop the signature and you get the terse one-liner shown earlier in the article.

andMap = Maybe.map2 (|>)

Symmetry

The andMap and map2 functions are two sides of the same coin. Both functions can be implemented in terms of the other.

andMap : Maybe a -> Maybe (a -> b) -> Maybe b
andMap =
  Maybe.map2 (|>)
map2 : (a -> b -> c) -> Maybe a -> Maybe b -> Maybe c
map2 function maybe1 maybe2 =
  Just function
    |> andMap maybe1
    |> andMap maybe2

In fact, you can implement any mapN function in terms of andMap. You wrap your n-argument function in Just and follow it with n pipes to andMap. For example if we wanted create an actual map6 function we could write it as:

map6 : (a -> b -> c -> d -> e -> f -> g)
  -> Maybe a
  -> Maybe b
  -> Maybe c
  -> Maybe d
  -> Maybe e
  -> Maybe f
  -> Maybe g
map6 sixArgFunction maybe1 maybe2 maybe3 maybe4 maybe5 maybe6 =
  Just sixArgFunction
    |> andMap maybe1
    |> andMap maybe2
    |> andMap maybe3
    |> andMap maybe4
    |> andMap maybe5
    |> andMap maybe6

Applicatives

If you read more formal functional programming literature, you may run into the term applicative. These are defined as entities that have all of the following:

  1. a constructor
  2. either map2 OR andMap defined for it

The Maybe, Json.Decode.Decoder, and Random.Generator types all meet these criteria and thus can be described as being applicative.

Applicatives have many interesting properties. The one we’ve explored in this article is tied to the definition itself: map2 and andMap are equivalent to each other and each can be defined in terms of the other. All other mapN functions can be built out of either of these.