danielwertheim

danielwertheim


notes from a passionate developer

Share


Sections


Tags


Disclaimer

This is a personal blog. The opinions expressed here represent my own and not those of my employer, nor current or previous. All content is published "as is", without warranty of any kind and I don't take any responsibility and can't be liable for any claims, damages or other liabilities that might be caused by the content.

Exceptions vs the Result type in F#

Lets assume we are designing a type that defines some lower and upper bounds. To keep this post simple, we define one rule:

"You shall not be able to create an instance of it, with a lower bound exceeding the upper bound."

Records & factory functions

For this post we are OK with the fact that an instance of a record can be created without forcing the use of a dedicated "factory" function. This gives us the ability to use the following solution:

type Bounds = {
    Lower: int
    Upper: int
}

module Bounds =
    let from lower upper =
        if lower > upper then
            invalidArg "lower" "Lower bound can not exceed upper bound."

        { Lower = lower; Upper = upper}
        
    let contains v bounds =
        v >= bounds.Lower && v <= bounds.Upper 

A valid solution, right? We can't create an invalid instance of Bounds when using Bounds.from.

//Does not throw
let okBounds = Bounds.from 1 10

//Throws an ArgumentException
let notOkBounds = Bounds.from 10 1

But it throws, is that OK in FP?

I'm not a hardcore FP programmer, hence I would dare to say: Yes. Sometimes. But there are alternatives to throwing. One being: Railway Oriented Programming (ROP)

I've enjoyed working with ROP, which relies on the Result<'T, 'TError> type found in F#. As we can see from the definition it has two outcomes:

Using this type and Result.bind and Result.map we can compose a pipeline from functions, passing data through from function-to-function, AS LONG AS IT STAYS ON THE HAPPY PATH. As soon as an error is the result, that error result falls through and ends up in a handler (function) that has defined how to handle the error.

rop-flowchart-1

Obviously the same could have been accomplished by throwing a dedicated exception that was handled in a try-with expression somewhere up the callstack. So why should we use the Result type?

Semantics and expressiveness

In my opinion, one great advantage of using the Result type, is the semantics and expressiveness it provides. It's clear that something can go wrong, that the function will not always return the intended result type. And used with e.g. a discriminated union representing possible errors, we get really good expressiveness & discoverability of what can go wrong.

Looking at a fictitious sample of placing an order, using invoice as a payment method:

type OrderId = OrderId of string
type InvoiceId = InvoiceId of string
type ProductId = ProductId of string
type Reason = Reason of string

type PlaceInvoiceOrderError =
| OutOfStock of ProductId list
| RiskToHigh
| CanNotShipToAddress of Reason

type PlaceInvoiceOrderEvent =
| InvoiceOrderPlaced of OrderId * InvoiceId

type PlaceInvoiceOrderCommand = {
    Products: ProductId list
}

type PlaceInvoiceOrder =
    PlaceInvoiceOrderCommand
        -> Result<PlaceInvoiceOrderEvent, PlaceInvoiceOrderError>

It's very clear what the potential outcomes are:

So no more raising of exceptions?

Why not? One direct benefit that I see about exceptions is the stack trace. It's much more simple to find where something has gone wrong. Especially if you have been reusing errors throughout your code base. And especially if you have been reusing them in multiple branches within a certain main flow.

ROP all the things?

I believe in being pragmatic. I do believe ROP offers a nice construct when it comes to the "outer most flow" of a certain process. Using infix operators like: >>= and >>-; we can make the code really readable when it comes to the "flow":

let handler cmd =
    cmd
    |> ensureItemsAreInStock
    >>= ensureWeCanShipToAddress
    >>= ensureCustomerRiskIsAcceptable
    >>- generateInvoiceOrderPlaced

How-ever, I don't believe everything need to be using ROP. E.g. when constructing an instance of our Bounds type. When the construction has no direct correlation to external input, or when there's no natural domain error for it, then there's an exceptional case and we should not really end up here. Then I would be fine with raising an exception and falling back on an outer most exception handler, that logs the exception and reports back e.g. a 500 HTTP status in a web API.

I can understand that it feels a bit weird when looking at the Bounds.from signature, that we might end up in not getting a result, as we might get an exception. What if we used a class instead? Will it feel better then?

Classes has constructors

F# does allow you to use classes, so we could solve the issue by defining a class like this:

type Bounds(lower:int, upper:int) =
    do
        if lower > upper then
            invalidArg "lower" "Lower bound can not exceed upper bound."

    member __.Lower = lower
    member __.Upper = upper

//Works
let okBounds = Bounds (1, 10)

//Throws
let notOkBounds = Bounds(10, 1)

I almost never use classes in F#. I just wanted you to see an alternative solution where you might feel that enforcing data integrity using exceptions is OK.

Scenario - OK with throwing

Lets wrap up by looking at a scenario where we use the Bounds type and where I find it OK to raise an exception.

I'm using the record-type solution here, but could just as well have used the class solution.

type Risk =
| Low
| Medium
| High
| OfTheCharts

module RiskAssessment =
    let private boundaries = [
        Bounds.from 1 10,Low
        Bounds.from 11 20, Medium
        Bounds.from 21 30, High]

    let assess v =
        boundaries
        |> Seq.tryFind (fun (b, _) -> b |> Bounds.contains v)
        |> Option.map (fun (_, r) -> r)
        |> Option.defaultValue OfTheCharts

printfn "%A" (RiskAssessment.assess 1)
printfn "%A" (RiskAssessment.assess 11)
printfn "%A" (RiskAssessment.assess 21)
printfn "%A" (RiskAssessment.assess 99)

Result:

Low
Medium
High
OfTheCharts

As long as the programmer defining the boundaries passes the values in the correct order, we are safe. As in, our RiskAssessment.assess function will always return a value. This is a scenario where I do think an exception is OK. If it throws, it's because incorrect usage from the programmers side.

That's it for this post/article. It's my current opinion about exceptions vs ROP all the things. But knowing me. I will probably go back and forth and re-evaluate this a bunch of times. So if you ask me later on, my opinion could very well have changed. Feel free to share your thoughts and educate me.

Cheers,

//Daniel

View Comments