Railway Oriented Programming and F# Result
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
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
let y = f () |> Result.bind (g >> Result.mapError (fun e -> e.Message))
Or let's say we cast
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
'TError should always be
let f () : Result<int, obj> = Ok 1 let g i : Result<int, obj> = Ok 1 let x = f () |> Result.bind g // Compiles fine now
Finding a homogeneous error type
This is a bit how exceptions in .NET works. We combine objects of heterogeneous types but all exceptions inherits a common base class
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
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)
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
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
rgood (fun x y z -> x + y + z) <*> rgood 1 <*> rmsg "Bad" <*> rmsg "Result" |> printfn "%A"
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
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.
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.