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

Observing a Result

tap and tapError run a side effect and hand the result back unchanged. Use them to log without breaking a pipeline apart.

Verified
open System
open Reified.Result

type SignupError =
    | AgeNotANumber of string
    | AgeOutOfRange of int

let parseAge (age: string) : Result<int, SignupError> =
    match Int32.TryParse age with
    | true, value -> Ok value
    | false, _ -> Error(AgeNotANumber age)

Without them

Logging mid-pipeline means naming the intermediate value and returning it again:

Fragment
let logged =
    let outcome = parseAge raw
    match outcome with
    | Ok age -> printfn "accepted %d" age
    | Error failure -> printfn "rejected: %A" failure
    outcome

With them

Verified
parseAge "abc"
|> Result.tap (fun age -> printfn "accepted %d" age)
|> Result.tapError (fun failure -> printfn "rejected: %A" failure)
rejected: AgeNotANumber "abc"

The value returned is the original Error (AgeNotANumber "abc"). tap did not run, because the result was not Ok; tapError ran and returned its input untouched.

Both callbacks return unit; neither changes the result:

Fragment
Result.tap      : ('value -> unit) -> Result<'value, 'error> -> Result<'value, 'error>
Result.tapError : ('error -> unit) -> Result<'value, 'error> -> Result<'value, 'error>

At a boundary

At a boundary, where you want a record of what happened but the caller still gets the untouched result:

Fragment
let handleSignup raw =
    parseAge raw
    |> Result.tapError (fun failure -> logger.Warning("signup rejected: {Failure}", failure))
    |> Result.map buildAccount

Keep the effect small and total. An effect that throws will propagate out of tap, which defeats the purpose of working in Result, and because the exception escapes mid-pipeline, the result you were carrying is lost.