Thoughts on adding List.map_result, List.fold_left_result and similar functions to the Stdlib

Hi!

I’m curious to know if there have been any attempts or thoughts about adding functions such as List.map_result and List.fold_left_result to the Stdlib. I.e. short-circuiting error supporting versions of commonly used functions (when working with the result type).

I might be missing something, but unless you are using exceptions, functions such as List.map_result and List.fold_left_result seem fundamental. Right now our team have added these functions on top of Stdlib.List to our codebase (fold_result being our name for fold_left_result):

val map_result : f:('b -> ('c, 'e) result) -> 'b t -> ('c t, 'e) result
val fold_result : f:('a -> 'b -> ('a, 'e) result) -> init:'a -> 'b t -> ('a, 'e) result

We have also similar functions on top of Stdlib.Map.

What are your thoughts on this?

It probably isn’t added because it’s easy to write your own version of these. A common pattern i’ve seen is:


let ( >>= )  = Result.bind 

List.fold_left
  (fun acc v ->
    acc >>= fun a ->
    ...
    Ok (a') (* return an updated accumulator *)) (Ok init) some_list

I am not aware of any serious attemp. One issue that comes to mind is that there are various possible definitions of such functions. For example, the “map” function over a list of result values could, alternatively:

  • stop at the first error (and return it as the result),
  • collect all errors and all successes and return both as the result
  • etc

As a purely organizational point, I think it is a better to put these functions in a submodule of the Result module, eg Result.List, rather than in List itself. That way, you can have Result.List.map (mapping over result values), Option.List.map (mapping over option values), etc.

Incidentally, many of these operations can be generalized to arbitrary monads, see eg how it is done in Dune’s codebase: dune/otherlibs/stdune/src/monad_intf.ml at main · ocaml/dune · GitHub.

Cheers,
Nicolas

1 Like

As @nojb mentioned the design is less self-evident than what your post suggests. In particular it’s not always very smart to stop at the first error. See for example the various versions here.

For the first error, it can be generalized by traverse through monad and for collecting all errors it can be generalized by traverse on through applicative (using a semigroup as collecting buffer).

Indeed, you can notice a common pattern for the shape of such functions like List.map_result that work with different types. And this pattern is usually called traverse:

val traverse : ('a ->  'b      option) -> 'a list -> ('b list)     option
val traverse : ('a -> ('b, 'e) result) -> 'a list -> ('b list, 'e) result
val traverse : ('a ->  'b      Lwt.t)  -> 'a list -> ('b list)     Lwt.t

val traverse : ('a ->  'b      option) -> 'a array-> ('b array)     option
val traverse : ('a -> ('b, 'e) result) -> 'a array-> ('b array, 'e) result
val traverse : ('a ->  'b      Lwt.t)  -> 'a array-> ('b array)     Lwt.t

(* and so on ... *)

You can see that implementing every single monomorphic function will result in a total of O(N x M) implementations which is quite a lot of boilerplate to write and maintain if you ask me.

Good news! It’s possible to implement a single implementation of traverse per a collection type if the function result type implements the following module signature known as Applicative:

module type Applicative = sig
  type 'a t
  val pure : 'a -> 'a t
  val both : 'a t -> 'b t -> ('a, 'b) t
end

(and types like option, result, Lwt.t and many others can trivially implement this).

Once you have this, you can generalise traverse, and its implementation will give you map_result and fold_result for free. So in some sense, it’s a nice modular abstraction that gives you quite a lot of flexibility.

As for the question about collecting errors, this is already happen to be solved as mentioned by @xvw. You can have the following type usually known as validation:

type ('a, 'e) validation =
  | Failure of 'e
  | Success of 'a

And the Applicative implementation for validation combines errors instead of returning the first one. Thus, traverse over a list collects all errors instead of short-circuiting on the first one. I think it’s a nice design because you don’t have the make a choice for an end user (users usually don’t like this), and you give more flexibility while allowing for extensibility.

However, whether it’ll be convenient enough to work with these Applicative and Traverse modules instead of monomorphic functions is a separate question :slightly_smiling_face:

5 Likes