Full source: https://gist.github.com/mrange/aa9e0898492b6d384dd839bc4a2f96a1
Option<_>
is great for ROP (Railway Oriented Programming) but we get no info on what went wrong (the failure value is None
which carries no info).
With the introduction F# 4.1 we got Result<_, _>
a "smarter" Option<_>
as it allows us to pass a failure value.
However, when one inspects the signature of Result.bind
one sees a potential issue for ROP:
val bind: binder:('T -> Result<'U, 'TError>) -> result:Result<'T, 'TError> -> Result<'U, 'TError>
bind
requires the same 'TError
for both result
and binder
.
This means that composing two functions like this is difficult:
let f () : Result<int, string> = Ok 1
let g i : Result<int, exn> = Ok 1
let x = f () |> Result.bind g // Doesn't compile as string doesn't match exn for 'TError
Result
allows us to map errors using Result.mapError
allowing us to overcome this issue by mapping exn
to string
:
let y = f () |> Result.bind (g >> Result.mapError (fun e -> e.Message))
Or let's say we cast 'TError
to obj
always:
let toObj v = v :> obj
let z = f () |> Result.mapError toObj |> Result.bind (g >> Result.mapError toObj)
Or more succinct:
let rerrorToObj t = Result.mapError (fun v -> v :> obj) t
let z = f () |> rerrorToObj |> Result.bind (g >> rerrorToObj)
It feels a bit clunky and if we always cast all error objects to obj
perhaps 'TError
should always be obj
?
let f () : Result<int, obj> = Ok 1
let g i : Result<int, obj> = Ok 1
let x = f () |> Result.bind g // Compiles fine now
This is a bit how exceptions in .NET works. We combine objects of heterogeneous types but all exceptions inherits a common base class exn
.
Exceptions and ROP wants to solve the same problem: "How can we make sure that happy path code isn't hidden by the error-handling for all unhappy paths".
It's not unreasonable to think that to enable ROP we need a homogeneous error type. I would suggest something other than obj
though.
One suggestion is this:
[<RequireQualifiedAccess>]
type RBad =
| Message of string
| Exception of exn
| Object of obj
| DescribedObject of string*obj
This allows us to pass messages, exceptions but also all kinds of objects that we didn't foresee as errors. For tracing we also allow describing an error object.
However, when implementing result combinators one realizes that there is a need to combine errors as well.
Consider the common functional pattern Applicative:
let r =
Ok someFunction
<*> argument1
<*> argument2
<*> argument3
<*> argument4
Applicative will apply argument 1 to 4 to someFunction
if and only if all arguments are Ok
. Otherwise it returns an Error
. It can be sensible that Error
contains all argument errors, not just the first one.
In addition there is sometimes the need to pair results:
let p = rpair (x, y)
But rpair
should also pair the error results if any.
In order to support that the error type could look like this:
[<RequireQualifiedAccess>]
type RBadTree =
| Leaf of RBad
| Fork of RBadTree*RBadTree
We are ready to define our result type:
type RResult<'T> = Result<'T, RBadTree>
But as type abbreviations has limitations I instead will define RResult<_>
as this:
[<RequireQualifiedAccess>]
[<Struct>]
type RResult<'T> =
| Good of good : 'T
| Bad of bad : RBadTree
It's easy to define common functions for this type:
let inline rreturn v = RResult.Good v
let inline rbind (uf : 'T -> RResult<'U>) (t : RResult<'T>) : RResult<'U> =
match t with
| RResult.Bad tbad -> RResult.Bad tbad
| RResult.Good tgood -> uf tgood
// Kleisli
let inline rarr f = fun v -> rreturn (f v)
let inline rkleisli uf tf = fun v -> rbind uf (tf v)
// Applicative
let inline rpure f = rreturn f
let inline rapply (t : RResult<'T>) (f : RResult<'T -> 'U>) : RResult<'U> =
match f, t with
| RResult.Bad fbad , RResult.Bad tbad -> RResult.Bad (fbad.Join tbad)
| RResult.Bad fbad , _ -> RResult.Bad fbad
| _ , RResult.Bad tbad -> RResult.Bad tbad
| RResult.Good fgood , RResult.Good tgood -> rreturn (fgood tgood)
// Functor
let inline rmap (m : 'T -> 'U) (t : RResult<'T>) : RResult<'U> =
match t with
| RResult.Bad tbad -> RResult.Bad tbad
| RResult.Good tgood -> rreturn (m tgood)
// Lifts
let inline rgood v = rreturn v
let inline rbad b = RResult.Bad (RBadTree.Leaf b)
let inline rmsg msg = rbad (RBad.Message msg)
let inline rexn e = rbad (RBad.Exception e)
As this is a type, not a type abbreviation, we can also extend it with common operators:
type RResult<'T> with
static member inline (>>=) (x, uf) = RResult.rbind uf x
static member inline (<*>) (x, t) = RResult.rapply t x
static member inline (|>>) (x, m) = RResult.rmap m x
static member inline (<|>) (x, s) = RResult.rorElse s x
static member inline (~%%) x = RResult.rderef x
static member inline (%%) (x, bf) = RResult.rderefOr bf x
Note that rapply
joins the bad results which can be seen in this example:
rgood (fun x y z -> x + y + z)
|> rapply (rgood 1 )
|> rapply (rmsg "Bad" )
|> rapply (rmsg "Result" )
|> printfn "%A"
Or more succinct using the rapply
operator <*>
:
rgood (fun x y z -> x + y + z)
<*> rgood 1
<*> rmsg "Bad"
<*> rmsg "Result"
|> printfn "%A"
This prints:
Bad (Fork (Leaf (Message "Bad"),Leaf (Message "Result")))
In Scott Wlaschin amazing ROP presentation he presents the an example on a workflow that needs error handling:
receiveRequest
>> validateRequest
>> canonicalizeEmail
>> updateDbFromRequest
>> sendEmail
Scott claims that with ROP the code with error handling and without error handling can be made to look the same. How does his example look with RResult<_>
?
fun uri ->
receiveRequest uri
>>= validateRequest
>>= canonicalizeEmail
>>= updateDbFromRequest
>>= sendEmail
Pretty good but can get even neater if one define the kleisli operator >=>
:
receiveRequest
>=> validateRequest
>=> canonicalizeEmail
>=> updateDbFromRequest
>=> sendEmail
At my work at Atomize AB I am been collecting information from various sources and combining them into results.
I found ROP to be a very useful pattern but Option<_>
doesn't work for me as I need to pass not only the result but also what went wrong if there was an error.
I found Result<_, _>
difficult to compose because the 'TError
type might be incompatible. In addition, I was lacking a way to aggregate multiple errors into a homogeneous result.
That's why I created something that look very much like RResult<_>
and that has proven itself useful as it's homogeneous error result simplifies ROP as well as it's ability to join error results is useful in my use case.
I hope this was interesting.