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.
-
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. ↩
Top comments (3)
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.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"
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.