Running Out of Result Maps
The Problem
The Elm Maybe
module
only defines Maybe.map, Maybe.map2, …, Maybe.map5. There is no
Maybe.map6. Similarly, the Elm Result
module
defines Result.map, Result.map2, …, Result.map5, and there is
no Result.map6.
In Running Out of
Maps, Joël
Quenneville gives a simple example why functions such as Maybe.map6
or Result.map6 are useful. He starts with a User record that is
defined as follows:
type alias User =
{ name : String
, age : Int
, address : String
, email : String
, role : Role
}
type Role = Admin | Regular
Defining the User record automatically defines a User record
constructor function that returns a User record when passing in
values corresponding to the different fields of the User record.
For example,
User "Alice" 42 "41 Winter Street" "alice@example.com" Regular
evaluates to
{ address = "41 Winter Street"
, age = 42
, email = "alice@example.com"
, name = "Alice"
, role = Regular
}
Since records with six or more fields are quite common, (record constructor) functions with six or more arguments are quite common as well.
Maybe.map5 can be used to create a User record wrapped in Just
if all the required data is present or Nothing if some piece of data
is missing.
Maybe.map5 User
(Just "Alice")
(Just 42)
(Just "41 Winter Street")
(Just "alice@example.com")
(Just Regular)
evaluates to
Just
{ address = "41 Winter Street"
, age = 42
, email = "alice@example.com"
, name = "Alice"
, role = Regular
}
while
Maybe.map5 User
Nothing
(Just 42)
Nothing
(Just "alice@example.com")
(Just Regular)
evaluates to
Nothing
Similarly,
Result.map5 User
(Ok "Alice")
(Ok 42)
(Ok "41 Winter Street")
(Ok "alice@example.com")
(Ok Regular)
evaluates to
Ok
{ address = "41 Winter Street"
, age = 42
, email = "alice@example.com"
, name = "Alice"
, role = Regular
}
while
Result.map5 User
(Err "Name not found")
(Ok 42)
(Err "Address not found")
(Ok "alice@example.com")
(Ok Regular)
evaluates to
Err ("Name not found")
Note that the result is Err ("Name not found") and not Err
("Address not found"). Result.map5 returns the first Err value it
encounters.
The Solution for the Maybe Case
If we extend the User record (e.g. by adding a field for storing
language preferences), we will need something to play the role of the
missing Maybe.map6 and Result.map6 functions.
In Running Out of
Maps, Joël
Quenneville solves this problem for the Maybe case. He defines
andMap : Maybe a -> Maybe (a -> b) -> Maybe b
andMap =
Maybe.map2 (|>)
and then shows that for any function fn of arity 6
Maybe.map6 fn x1 x2 x3 x4 x5 x6
is the same as
Just fn
|> andMap x1
|> andMap x2
|> andMap x3
|> andMap x4
|> andMap x5
|> andMap x6
The andMap pipeline needs a bit more code but it is still easy read
and write.
Before we look at the Result case, let’s try to get a better
understanding of what andMap does. The first argument passed into
andMap is either Nothing or Just v where v is some value of
type a.The second argument passed into andMap is either Nothing
or Just fn where fn is a function that takes an a and returns a
b. Let’s see what andMap does in detail.
andMap (Just v) (Just fn)
== Maybe.map2 (|>) (Just v) (Just fn)
== Just ((|>) v fn)
== Just (fn v)
andMap Nothing (Just fn) == Maybe.map2 (|>) Nothing (Just fn) == Nothing
andMap (Just v) Nothing == Maybe.map2 (|>) (Just v) Nothing == Nothing
andMap Nothing Nothing == Maybe.map2 (|>) Nothing Nothing == Nothing
If any of the arguments passed in is Nothing, then andMap
immediately returns Nothing because Maybe.map2 returns Nothing
if any of the arguments passed in is Nothing.
If both the function and the value “are present”, andMap unwraps
both arguments, applies the function to the value, and wraps the
result in Just.
Elm allows the construction of types that seem to lack an obvious
interpretration. In the type signature of andMap we see a Maybe
(a-> b) type. What does it mean to work with functions that may not
be present? This is hard to understand for people coming from a more
object oriented or imperative background. Nevertheless, It is easy to
write down concrete examples for such types. According to he Elm REPL,
the type of Just (\n -> n + 1) is Maybe (number -> number). The
Elm REPL has not problem with these type because it does not care
about their meaning and because it treats functions just like it
treats any other value.
A First Look at the Result Case
Following Running Out of Maps, we define a function
andMapR_ : Result x a -> Result x (a -> b) -> Result x b
andMapR_ =
Result.map2 (|>)
The capital R in andMapR_ indicates that andMapR_ is defined for
the result case. We will later define andMapR (without the
underscore) in a different way.
We can now use the Elm REPL to see that
Ok User
|> andMapR_ (Err "Name not found")
|> andMapR_ (Ok 42)
|> andMapR_ (Err "Address not found")
|> andMapR_ (Ok "alice@example.com")
|> andMapR_ (Ok Regular)
evaluates to
Err ("Address not found")
This is somewhat unexpected since
Result.map5 User
(Err "Name not found")
(Ok 42)
(Err "Address not found")
(Ok "alice@example.com")
(Ok Regular)
evaluates to
Err ("Name not found")
Simply copying the the construction of andMap from Running Out of
Maps does not work
for Result values. The andMapR_ pipeline returns Err ("Address
not found") instead of the expected Err ("Name not found"). Maybe
we can fix this by defining another function that plays the role of
andMap when dealing with Result values.
The Result.map2 Case
Let’s look at the simplest Result case. We can verify (e.g. using
the Elm REPL) that
andMapR_ : Result x a -> Result x (a -> b) -> Result x b
andMapR_ =
Result.map2 (|>)
is not working as expected.
Result.map2 fn2 (Err e1) (Err e2)
is not the same as
Ok fn2
|> andMapR_ (Err e1)
|> andMapR_ (Err e2)
The Result.map2 call returns Err e1 while the andMapR_ pipeline
returns Err e2. But why is this?
To get a better understanding of where things break, we need a better
understanding of what andMapR does. Similar to the andMap case for
Maybe, we have the following identities:
andMapR_ (Ok v) (Ok fn)
== Result.map2 (|>) (Ok v) (Ok fn)
== Ok ((|>) v fn)
== Ok (fn v)
andMapR_ (Err e) (Ok fn) == Result.map2 (|>) (Err e) (Ok fn) == Err e
andMapR_ (Ok v) (Err e) == Result.map2 (|>) (Ok v) (Err e) == Err e
andMapR_ (Err e1) (Err e2) == Result.map2 (|>) (Err e1) (Err e2) == Err e1
If any of the values passed in is an Err value, then andMap_
immediately returns some Err value because Result.map2 returns an
Err value. The last case shown above is the interesting one. If both
arguments passed in are Err values, then andMapR_ returns the the
first one.
If both the function and the value are Ok values, then andMapR_
unwraps both arguments, applies the function to the value, and wraps
the result in Ok.
We have seen that In the andMap case, we have
andMap Nothing Nothing == Maybe.map2 (|>) Nothing Nothing == Nothing
while in the andMapR_ case, we have
andMapR_ (Err e1) (Err e2) == Result.map2 (|>) (Err e1) (Err e2) == Err e1
In the andMap case, we simply return Nothing. There is no first
and second Nothing. All Nothings are equal. In the andMapR_
case, order matters. andMapR_ returns the first Err value it
encounters.
Now let’s look at what happens with in an andMapR_ pipeline if we pass in
two Err values. We start by rewriting the pipeline in one line.
Ok fn |> andMapR_ (Err e1) |> andMapR_ (Err e2)
== (Ok fn |> andMapR_ (Err e1)) |> andMapR_ (Err e2) -- add parentheses
== (andMapR_ (Err e1) (Ok fn)) |> andMapR_ (Err e2) -- apply pipe operator
== Err e1 |> andMapR_ (Err e2) -- see above_
== andMapR_ (Err e2) (Err e1) -- apply pipe operator
== Err e2 -- see above
It looks like the problem shows up in the last step where we apply
andMapR_ to two Err values. andMapR_ returns the first Err
value passed in. Let’s construct something that returns the second
Err value instead.
The following implementation of andMapR behaves identically to
andMapR_ except for the case where two Err values are passed
in. In this case, andMapR returns the second Err value instead of
the first one.
andMapR : Result x a -> Result x (a -> b) -> Result x b
andMapR ra rb =
case ( ra, rb ) of
( Ok v, Ok fn ) ->
Ok (fn v)
( Err e, Ok _ ) ->
Err e
( Ok _, Err e ) ->
Err e
( Err _, Err e ) ->
Err e
Now we have to check that a pipeline of two andMapR calls is
actually the same as Result.map2. In the transformations below,
fn2 always denotes a function of two arguments (with the matching
type signature).
Let’s first get rid of the pipe operator so that it is easier to
determine the values of andMapR function application
Ok fn2 |> andMapR x1 |> andMapR x2
== andMapR x1 (Ok fn2) |> andMapR x2 -- apply pipe operator
== andMapR x2 (andMapR x1 (Ok fn2)) -- apply pipe operator
Case 1: x1 = Ok v1, x2 = Ok v2
andMapR x2 (andMapR x1 (Ok fn2))
== andMapR (Ok v2) (andMapR (Ok v1) (Ok fn2))
== andMapR (Ok v2) (Ok (fn2 v1)) -- definition of andMapR
== Ok ((fn2 v1) v2) -- definition of andMapR
== Ok (fn2 v1 v2) -- partial application
== Result.map2 fn2 (Ok v1) (Ok v2) -- definition of Result.map2
Case 2: x1 = Ok v1, x2 = Err e2
andMapR x2 (andMapR x1 (Ok fn2))
== andMapR (Err e2) (andMapR (Ok v1) (Ok fn2))
== andMapR (Err e2) (Ok (fn2 v1)) -- definition of andMapR
== Err e2 -- definition of andMapR
== Result.map2 fn2 (Ok v1) (Err e2) -- definition of Result.map2
Case 3: x1 = Err e1, x2 = Ok v2
andMapR x2 (andMapR x1 (Ok fn2))
== andMapR (Ok v2) (andMapR (Err e1) (Ok fn2))
== andMapR (Ok v2) (Err e1) -- definition of andMapR
== Err e1 -- definition of andMapR
== Result.map2 fn2 (Err e1) (Ok v2) -- definition of Result.map2
Case 4: x1 = Err e1, x2 = Err e2
andMapR x2 (andMapR x1 (Ok fn2))
== andMapR (Err e2) (andMapR (Err e1) (Ok fn2))
== andMapR (Err e2) (Err e1) -- definition of andMapR
== Err e1 -- definition of andMapR
== Result.map2 fn2 (Err e1) (Err e2) -- definition of Result.map2
We see that with the new andMapR function, a pipeline of two
andMapR calls is identical to Result.map2. In particular, just
like Result.map2, the pipeline of two andMapR calls returns the
first Err it encounters.
Generalization to More Arguments and Longer Pipelines
This section essentially repeats the computations of the previous section in an attempt to prove the general case using induction. It does not provide any new insights. This article is not a peer-reviewed academic paper. If you find any error or typo, please let me know.
Subscripts are hard to write in markdown code blocks. Therefore, we
are going to change notation a bit. We will be writing (e.g.) f2 as
f<2> where the integer in angle brackets plays the role of a
subscript. Using this notation, we have proven in the last section
that
Ok fn<2>
|> andMapR x<1>
|> andMapR x<2>
is the same as
Result.map2 fn<2> x<1> x<2>
Let’s assume that for any integer k, we have
Ok fn<k>
|> andMapR x<1>
|> andMapR x<2>
...
|> andMapR x<k>
is the same as
Result.map<k> fn<k> x<1> x<2> ... x<k>
By applying the first k andMapR pipeline steps, we see that
Ok fn<k+1>
|> andMapR x<1>
|> andMapR x<2>
...
|> andMapR x<k>
|> andMapR x<k+1>
is the same as
(Result.map<k> fn<k+1> x<1> x<2> ... x<k>) |> andMapR x<k+1> (1)
It remains to show that the latter expression is the same as
Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
Note that in expression (1) we are passing the function fn<k+1> as
the first argument to Result.map<k> and we are also providing only
k arguments x<1>, x<2>, …, x<k>. This means that expression
(1) actually returns a function of one argument wrapped in a
Result. This is exactly what the andMapR function expects as its
second argument.
Let’s start by applying the pipeline operator in expression (1). We
see that (1) is the same as
andMapR x<k+1> (Result.map<k> fn<k+1> x<1> x<2> ... x<k>)
Again, we consider four cases.
Case 1: x<k+1> = Ok v1, (Result.map<k> fn<k+1> x<1> x<2> ... x<k>) = Ok fn
andMapR x<k+1> (Result.map<k> fn<k+1> x<1> x<2> ... x<k>)
== andMapR (Ok v1) (Ok fn)
== Ok (fn v1)
== Ok ((fn<k+1> x<1> x<2> ... x<k>) x<k+1>)
== Ok (fn<k+1> x<1> x<2> ... x<k> x<k+1>)
== Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
Case 2: x<k+1> = Ok v1, (Result.map<k> fn<k+1> x<1> x<2> ... x<k>) = Err e
andMapR x<k+1> (Result.map<k> fn<k+1> x<1> x<2> ... x<k>)
== Err e
== Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
Since, by construction, Err e is the first error passed passed into
Result.map<k> fn<k+1> x<1> x<2> ... x<k>
it is also the first error passed into
Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
Case 3: x<k+1> = Err e, (Result.map<k> fn<k+1> x<1> x<2> ... x<k>) = Ok fn
andMapR x<k+1> (Result.map<k> fn<k+1> x<1> x<2> ... x<k>)
== Err e
== Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
Again, we see that x<k+1> = Err e is the first error passed into
Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
because x<1>, x<2>, …, x<k> are all Ok values by construction.
Case 4: x<k+1> = Err e1, (Result.map<k> fn<k+1> x<1> x<2> ... x<k>) = Err e2
andMapR x<k+1> (Result.map<k> fn<k+1> x<1> x<2> ... x<k>)
== Err e2
== Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
Here, Err e2 is the first error value passed into
Result.map<k> fn<k+1> x<1> x<2> ... x<k>
and we show that it is also the first Err value passed into
Result.map<k+1> fn<k+1> x<1> x<2> ... x<k> x<k+1>
The Implementation of andMap in Result.Extra
Interestingly, the
Result.Extra
Elm module also contains an implementation of andMap that looks as follows:
andMap : Result e a -> Result e (a -> b) -> Result e b
andMap ra rb =
case ( ra, rb ) of
( _, Err x ) ->
Err x
( o, Ok fn ) ->
Result.map fn o
This is different from the implementation of andMapR above (mainly
because it uses Result.map) but thinking through all the possible
cases of ra and rb shows Result.Extra.andMap does the same as
andMapR.