Introduction
One of the things I like most about F# is its language extensibility through computation expressions (CEs).
CEs let developers introduce new concepts that typically require native language-level support.
I've found CEs to be very ergonomic for writing reliable, exception-free code using Result types - a style sometimes called "railway-oriented programming" or "result-oriented programming".
I aimed to create an up-to-date guide as a gentle introduction for developers unfamiliar with F# or the FP school of thought. Using await/async as a starting point, we’ll see how the presented techniques can be understood as a more generalized form of this familiar pattern.
To kick things off, let’s look at an example of asynchronous code.
The F# code above is similar to its C# counterpart, but two things stand out: the task {...}
block and let!
.
Intuitively, let! html = ...
should work like var html = await ...
,
but the task {...}
block in the function body lacks a direct counterpart.
These blocks are used to define computation expressions - a flexible programming construct.
F# syntax at a glance
let myString = "hello world"
– F# uses let
to bind variables.let myFunction (num: int) = num + 3
– Functions are defined using let
with parameters. All functions return values.let ten = myFunction 7
– calling functions does not require parentheses.identifier : type
– Variable, parameter and function types can be annotated, like the (num: int)
integer parameter above.
Most of the time type annotations may be ommitted. The F# compiler automatically infers them. In this text, I include or skip type annotations for better reading flow.
From async/await to computation expressions
Languages like C# and TypeScript provide the async/await machinery for handling asynchronous operations.
In C#, await
takes a Task<TValue>
and returns the unwrapped TValue
as the result of the asynchronous operation.
Instead of an await
operator which only works with Task<TValue>
, F# offers a generalized mechanism for unwrapping values using let! myVariable = ...
.
The way let!
unwrapping works — and which types it supports — is determined by the surrounding CE block. Inside a task {...}
block, let! myVariable = ...
unwraps a Task<TValue>
computation to its value, much like how await
works with Task<TValue>
in C#.
The types accepted by let!
are defined by the computation expression block. For example, in an option {...}
block, let!
works with Option<TValue>
types only - it can't be used to unwrap a Task<TValue>
. Attempting to do so will result in a compilation error.
Various computation expressions in F#
CE implementations can be supplied by developers. F# comes with several CEs:
seq {...}
for sequences:IEnumerable<'T>
option {...}
forOption<'T>
, which is similar toNullable<TObj>
lazy {...}
for delayed computations:System.Lazy<'T>
- ...
Result-based error handling
Errors in code can be represented in various ways, such as special values (null, -1, 0), exceptions, or multiple return values.
F# features Discriminated Union types, which enable a single type to represent multiple distinct cases. This allows us to model the outcome of a computation—whether it succeeded or failed—as cases of a Result type.
This is how we can construct and call a function that returns Result<>
.
When dealing with Result<>
, we need a way of retrieving the values from the cases.
This is done using pattern matching.
This approach has several benefits.
- Errors become an explicit part of the program's type system.
- We can't accidentally miss an error case -
Result<>
s have to be unwrapped before they can be used. - We don't have uninitialized/default state variables - like
out
andref
or(success, error)
tuples. - We won't get surprised by missed exceptions at runtime as often.
In practice, you may still encounter some exceptions in the standard and external libraries, see Caveats.
Chaining pattern matches
Let's look at some code that combines multiple Result<>
-returning functions.
Our sign-up function handles the Ok
and Error
cases of all called functions. For each matched Error
case, we return an Error
case.
If all called functions return Ok
, we create a User record { User.Email = ...}
, wrap it inside an Ok
case, and return it.
This determines the return type of our function as Result<User, string>
.
Although the above code works, it becomes evident that additional pattern matches will cause more nesting.
Every time we access a Result<>
-wrapped value, we need to handle the potential error case. If we just want to propagate the error, we're forced to write repetitive error path code.
result {...} computation expression
CEs provide an intuitive way of working with Result<>
types.
Here, let!
is used in the context of the result {...}
block.
result {...}
defines the unwrapping as:
- The value needs to be of type
Result<TSuccess, TError>
. - If the value is an
Ok<TSuccess>
case, get the wrappedTSuccess
and bind it. - If the value is an
Error<TError>
case, get the wrappedTError
, abort further processing of the block, and return the error value.
Compared to the previous approaches, the result {...}
CE allows us to write the code in a linear fashion as if the programming language had native support for Result<>
types.
validation {...} for greedy error handling
result {...}
always terminates on the first error.
If we want to collect multiple errors, we can use validation {...}
.
Calling tryUserSignup
with all inputs rejected returns a list of error strings wrapped in an Error
case; printfn
is never reached.
validation {...}
is identical to result {...}
, except it allows to unwrap multiple Results in one step.
The trick lies in F#'s multiple assignment expression let! ... and! ...
which binds independent variables in one logical step.
Instead of terminating on the first error, validation {...}
will collect all errors that occur in the chain of variable bindings.
Because multiple errors may be returned at once, the return type is Result<TSuccess, TError list>
.
validation {...}
is very useful for parsing user-generated data, as it returns all errors in a batch, avoiding unnecessary back-and-forth.
Combining and nesting CEs
The problem: How do we deal with combinations like Task<Result<>>
?
Assume we have a function that inserts a user into a database asynchronously. Success is communicated via Result<>
.
We can't just use a result {...}
block, because our value is wrapped in an Task<>
. We need to take care of that first.
Approach 1: Nesting
Nesting works, but it doesn't solve the issue of mixing async and sync code in .NET.
We're forced to evaluate the async expression using GetAwaiter().GetResult()
before we can use its value for further processing. This is problematic.
For CEs that evaluate synchronously, this isn't an issue.
Approach 2: asyncResult {...} and friends
For task
, the alternative is to use special CEs, like taskResult {...}
available in FsToolkit.ErrorHandling.
The library covers combinations like taskResult {...}
, taskValidation {...}
, and many others.
Mixing CEs with pattern matching
It is always possible to use pattern matching instead of CEs.
Pattern matching is often the most pragmatic option. It makes sense when the resulting code is simpler or when you want to keep the number of CEs in your code base low.
Caveats
There are some drawbacks with Result-based error handling in general and its use in .NET.
-
Result-based error handling isn't universally better than exceptions:
- For a list of reasons, see "Against railway oriented programming".
- Still, "errors as values" is a viable default - newer popular languages seem to confirm this approach.
-
.NET's legacy of exception use:
- The .NET runtime, standard library (BCL) and most of the community code relies on exceptions.
- Exceptions will leak into your code unless you isolate dependencies.
asyncResult
andtaskResult
imply I/O.- Less of a concern if I/O and core logic are separated, like in the functional core, imperative shell architecture.
- Less of a concern when using F# libraries or frameworks.
- The impact can be mitigated using wrapper classes and static analysis.
Getting started
If you haven't tried F#, or worked with Result types yet, I recommend starting with pattern matching. This site is a great source for learning more about the approach, although it doesn't cover CEs which solve many of the complexities.
Once you've grasped the building blocks, you can try refactoring your code using the result {...}
CE from the excellent FsToolkit.ErrorHandling package.
By loading the comments you consent with GitHub's data protection policy.
Your choice will be remembered for your current browser.