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
Maybe
s. 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:
- a constructor
- either
map2
ORandMap
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.