loading...

F# Error Handling with 'Result'

jhewlett profile image Justin Hewlett ・4 min read

Handling errors with Result is a great way to model errors in your domain and force you to consider all the error cases1. But if you're used to using exceptions for this sort of thing, it can be tricky to figure out the best way to glue all your Result-returning functions together.

To illustrate some different approaches, we'll go through a real-life example of making an HTTP request, checking the status code, then attempting to decode the response body.

Here are the types we'll be working with to make our request:

type Response = {
    Body : string
    StatusCode : int
}

type RequestError =
| NetworkError

let makeHttpRequest (url : string) : Async<Result<Response, RequestError>> = ...

This can return an Error in case there's a network issue. And even if the request does succeed, we need to consider the possibility of a non-200 status code.

Here's our User type and a function to decode a string. If it fails to parse, it will return an Error with a message.

type User = {
    Id : string
    Name : string
}

let decodeUser (body : string) : Result<User, string> = ...

I deliberately left out the contents of makeHttpRequest and decodeUser; we're only interested in the type signatures here.

The most interesting code we'll look at is the getUser function below. It ties everything together and allows us to explore all the different ways to combine the error-handling logic.

Pattern Matching

We'll start with a pretty straightforward variant:

type GetUserError =
| NetworkError
| Non200Response
| ParseError of string

let getUser (url : string) : Async<Result<User, GetUserError>> =
    async {
        let! result = makeHttpRequest url

        return
            match result with
            | Error _ -> Error NetworkError
            | Ok response ->
                match response.StatusCode with
                | 200 ->
                    match decodeUser response.Body with
                    | Error err -> Error (ParseError err)
                    | Ok user -> Ok user
                | _ ->
                    Error Non200Response
    }

We use an async computation expression, then pattern match to handle all the errors. This is simple and pretty easy to write, though there's a lot of nesting, and it's needlessly verbose in a few cases. All the plumbing is out there in the open, for better or worse.

Towards a Pipeline

We can eliminate some of the plumbing by embracing Result.bind and Result.mapError:

let getUser (url : string) : Async<Result<User, GetUserError>> =
    async {
        let! result = makeHttpRequest url

        return
            result
            |> Result.mapError (fun _ -> NetworkError)
            |> Result.bind (fun response ->
                match response.StatusCode with
                | 200 ->
                    response.Body
                    |> decodeUser
                    |> Result.mapError ParseError
                | _ ->
                    Error Non200Response
            )
    }

Result.bind chains actions together in the success case. Result.mapError takes any failures that occur along the way and maps them to a common GetUserError type.

Embrace the Pipeline

So far we have a bit of a hybrid, using a computation expression for async, and function pipelines for dealing with Result. That's fine, but let's see how it feels to go all-in.

Here we'll use the AsyncResult module from FsToolkit.ErrorHandling which allows us to drop the async computation expression:

let getUser (url : string) : Async<Result<User, GetUserError>> =
    makeHttpRequest url
    |> AsyncResult.mapError (fun _ -> NetworkError)
    |> AsyncResult.bind (fun response ->
        match response.StatusCode with
        | 200 ->
            response.Body
            |> decodeUser
            |> Result.mapError ParseError
            |> Async.singleton
        | _ ->
            AsyncResult.returnError Non200Response
    )

Not too much is different here. We use mapError and bind from AsyncResult instead of Result. We also have to use Async.singleton to lift our decode Result into Async<Result>.

Nesting is pretty minimal, and it's pretty easy to see how our data flows from request to status code checking to decoding the response.

Computation Expressions, My Old Friend

All right, let's see how things would look if we instead go the other way and lean harder into computation expressions. We'll use the asyncResult builder from FsToolkit.ErrorHandling:

let getUser (url : string) : Async<Result<User, GetUserError>> =
    asyncResult {
        let! response =
            makeHttpRequest url
            |> AsyncResult.mapError (fun _ -> NetworkError)

        do!
            response.StatusCode = 200
            |> Result.requireTrue Non200Response

        return!
            response.Body
            |> decodeUser
            |> Result.mapError ParseError
    }

Nesting is eliminated completely — computation expressions are good at that. We traded in our pattern matching on response.StatusCode for an interesting helper, Result.requireTrue. Part of me prefers the explicit pattern matching, but Result.requireTrue seems to fit the style better here.

Of all the variants, this one seems to do the best job of separating the distinct phases of making the request, checking the status code, and decoding the response. Unfortunately, it also seems to give the worst compiler errors, e.g. No overloads match for method 'Bind' if you forget to call AsyncResult.mapError on your response Result. Computation expressions are awesome, but you almost need to understand how they work under the hood to be able to understand errors like this.

Conclusion

Which of these approaches is the best? It's not clear to me. All of the variants seem to come with their own set of trade-offs. Some are easier to write, but harder to read (and vice versa). Some are friendlier to beginners, and others use more advanced language features.

But that's kind of the point. There's no one best way to do it. It depends on the context, and you need to play around with it to find something that feels right. In the end, it may come down to personal or team preference.

I encourage you to get comfortable with all of the approaches I outlined. Start simple, and if you're not happy with it, then try a different approach and compare.


  1. Scott Wlaschin has written a lot on this topic. He coined the term Railway Oriented Programming to describe this style of monadic error handling. More recently, he cautioned about taking it too far. I like his distinction between domain errors, infrastructure errors, and panics as a guideline for whether you should use Result or exceptions. 

Discussion

pic
Editor guide
Collapse
shimmer profile image
Brian Berns

I’m a fan of computation expressions, since they hide most of the boilerplate. One thing I don’t understand, though: Why would you ever get “No overloads match for method 'Bind'” as an error message from makeHttpRequest? That sounds more like a compiler error than an HTTP runtime error.

Collapse
jhewlett profile image
Justin Hewlett Author

Yes, it's a compiler error when your types don't line up in the CE. I'm just saying that it's hard to know what you're doing wrong as you're writing it when you get cryptic compiler errors like that.

I'll clarify by changing "error message" to "compiler error"

Collapse
shimmer profile image
Brian Berns

Ah, I see. I agree that’s not a helpful compiler error at all. Might even be worth reporting it to the F# team as an issue.