Collect-like function that doesn't give up on errors
It would be useful to have a function that behaves like collect except that it doesn't stop when it encounters an error. Instead it always runs for every item and if there are any errors, it fails with a list of those errors, otherwise it succeeds with a list of values.
separate : List (Result e a) -> ( List e, List a )
separate =
List.foldl
(\result ( errors, oks ) ->
case result of
Ok ok ->
( errors, ok :: oks )
Err error ->
( error :: errors, oks )
)
( [], [] )
collectAll : (a -> Script x b) -> List a -> Script (List x) (List b)
collectAll getScript =
Script.collect (getScript >> Script.attempt)
>> Script.thenWith
(\results ->
case separate results of
( [], oks ) ->
Script.succeed oks
( errors, _ ) ->
Script.fail errors
)
One advantage of using this over collect is that you can provide better error messages. Rather than having the user having to fix each problem they encounter one at a time, they can be presented with a list of everything that went wrong.
I like this idea, but I'd be inclined to modify it slightly:
collectAll : (a -> Script x b) -> List a -> Script ( x, List x ) (List b)
This way the user never has to consider the possibility that the script might 'fail' with an empty list of errors. And I think the slightly more complex type signature is OK given that there's collect for the simple case.
I started looking at this and realized that do, each and sequence could all potentially use the same treatment - but following the same pattern, eachAll is a travesty 😛 Any thoughts on a naming pattern that might work for all four functions? One option would just be to suffix each function with _; it looks a bit ugly but I'm pretty OK with how it's turned out with at and at_ in elm-units and it's vaguely similar to the relationship between things like mapM and mapM_ in Haskell.
Haha, eachAll doesn't exactly roll off the tongue. I think adding All is better than _ though. To me, _ makes more sense when a function is identical to another except the argument order has been changed.
Here's another possibility I just thought of (open to alternate names for ExecutionPolicy and its various implementations):
-- Opaque type that defines how to run a list of scripts and collect errors/results
type ExecutionPolicy x a y b
= ExecutionPolicy (List (Script x a) -> Script y b)
-- Either fail with the first error (and stop there), or succeed with all results
stopOnFirstError : ExecutionPolicy x a x (List a)
-- Don't stop on error; either report all errors, or succeed with all results
reportAllErrors : ExecutionPolicy x a ( x, List x ) (List a)
-- Wrap each script in 'attempt' and simply return a list of Results
attemptAll : ExecutionPolicy x a Never (List (Result x a))
-- could come up with other kinds of execution policies, or potentially
-- allow users to construct their own custom ones
sequence : List (Script x a) -> Script x (List a)
sequence =
sequenceWith { executionPolicy = stopOnFirstError }
sequenceWith :
{ executionPolicy : ExecutionPolicy x a y b }
-> List (Script x a)
-> Script y b
sequenceWith { executionPolicy } scripts =
let
(ExecutionPolicy policy) =
executionPolicy
in
policy scripts
do : List (Script x ()) -> Script x ()
-- The whole point of 'do' is to take something that would otherwise result in
-- a script with a 'List ()' return type and collapse that to just '()', so
-- reflect this in the required execution policy type (which means that
-- 'attemptAll' couldn't be used here)
doWith :
{ executionPolicy : ExecutionPolicy x () y (List ()) }
-> List (Script x ())
-> Script y ()
each : (a -> Script x ()) -> List a -> Script x ()
-- Same restriction as 'do'
eachWith :
{ executionPolicy : ExecutionPolicy x () y (List ()) }
-> (a -> Script x ())
-> List a
-> Script y ()
collect : (a -> Script x b) -> List a -> Script x (List b)
collectWith :
{ executionPolicy : ExecutionPolicy x b y c }
-> (a -> Script x b)
-> List a
-> Script y c
Then you can write Script.collect function values but also
Script.collectWith { executionPolicy = Script.reportAllErrors } function values
That's an interesting idea! I guess if it's worth using comes down to how often the user needs to use different kinds of execution policies. If they only want the fail immediately version for example, then this might just be unnecessary mental overhead. I think it's worth trying this out though.
Yeah, I definitely think that the "fail immediately" version should be the default (the non-*With version) so you don't have to worry about execution policies at all in the simple case. And I can do things with the docs to avoid clutter/too much mental overhead, like moving all the *With functions to the bottom in an 'Advanced' section or similar, but link to them from the simple versions with a note like "If you want to collect all errors instead of just the first...".