Repository F# setup
open Reified
open Reified.Refinements
open Reified.Result
open Reified.Schema.Json

Working with collections

Applying a fallible operation to every item in a list leaves you with a list of results, which is rarely what you want. traverse and sequence turn that inside out: one result holding every value.

open System
open Reified.Result

type SignupError =
    | AgeMissing
    | AgeNotANumber of string
    | AgeOutOfRange of int

let parseAge (raw: string) =
    Int32.TryParse raw
    |> Result.fromTry
    |> Result.orError (AgeNotANumber raw)
    |> Result.bind (fun age ->
        if age >= 0 && age < 130 then Ok age else Error (AgeOutOfRange age))

traverse

traverse maps each item with a Result-returning function and collects the successes.

[ "1"; "2"; "3" ] |> Result.traverse parseAge
// Ok [1; 2; 3]

[ "1"; "abc"; "500" ] |> Result.traverse parseAge
// Error (AgeNotANumber "abc")

sequence

sequence is the same operation when you already hold the results — it is traverse id.

[ Ok 1; Ok 2 ] |> Result.sequence
// Ok [1; 2]

[ Ok 1; Error AgeMissing; Ok 3 ] |> Result.sequence
// Error AgeMissing

It stops at the first error

Traversal is fail-fast, and this is observable: the mapping does not run for items after the failure. This matters when the mapping does real work — a lookup, a request, a write.

let mutable visited = []

let recordAndParse raw =
    visited <- raw :: visited
    parseAge raw

[ "1"; "abc"; "3" ] |> Result.traverse recordAndParse
// Error (AgeNotANumber "abc")

List.rev visited
// ["1"; "abc"]

"3" was never visited. Only the first failure is reported, and later items are not examined at all — so this cannot tell a user everything wrong with their input.

traverseAll and sequenceAll

Use these when every item should be tried and every failure reported:

[ "1"; "abc"; "500" ] |> Result.traverseAll parseAge
// Error [AgeNotANumber "abc"; AgeOutOfRange 500]

[ Ok 1; Error AgeMissing; Error (AgeNotANumber "x") ] |> Result.sequenceAll
// Error [AgeMissing; AgeNotANumber "x"]

The rules:

  • Every mapping runs, in input order, even after one has failed. If the mapping does real work, it does that work for every item.
  • Errors come back in input order, one per failing item. Nothing is flattened: a mapping that itself returns Result<_, 'error list> produces a list of lists, so map it down first if you want one flat list.
  • The input sequence is enumerated once, up front, so a lazy sequence is fully forced and an infinite one will not terminate.

To accumulate across differently-typed independent steps rather than across one collection, see collecting every error.

Shape in and shape out

Both take any seq<_> and produce a list:

Result.traverse    : ('a -> Result<'b, 'e>) -> seq<'a> -> Result<'b list, 'e>
Result.sequence    : seq<Result<'a, 'e>> -> Result<'a list, 'e>
Result.traverseAll : ('a -> Result<'b, 'e>) -> seq<'a> -> Result<'b list, 'e list>
Result.sequenceAll : seq<Result<'a, 'e>> -> Result<'a list, 'e list>

An array or a seq goes in; a list comes out. Convert afterwards with Result.map when you need a different shape:

[| "1"; "2" |] |> Result.traverse parseAge |> Result.map Array.ofList
// Ok [|1; 2|]

traverse and sequence enumerate the input up to the failure point; traverseAll and sequenceAll enumerate all of it. An infinite sequence will not terminate through any of them.