Result-oriented programming with F# computation expressions

Quick intro to F# computation expressions and how they can be used for result-based error handling.

.NET F# Functional Programming FsToolkit.ErrorHandling
Cover image for article Result-oriented programming with F# computation expressions

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.

async Task<string> FetchUrlAsync(string url)
{
    var httpClient = new HttpClient();
    var html = await httpClient.GetStringAsync(url);
    return html;
}
C# code to fetch a URL asynchronously...
let fetchUrlAsync (url: string): Task<string> =
    task {
        let client = new HttpClient()
        let! html = client.GetStringAsync url
        return html
    }
... and its F# counterpart.

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.

C# await: Task<TVal>     -> TVal | Only for Task<>.
F# let!:  TWrapper<TVal> -> TVal | For any TWrapper<>, within supporting block {...}.
Parallels between C# async and F# let!

Various computation expressions in F#

CE implementations can be supplied by developers. F# comes with several CEs:

  • seq {...} for sequences: IEnumerable<'T>
  • option {...} for Option<'T>, which is similar to Nullable<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.

type Result<'TSuccess,'TError> = 
    | Ok of ResultValue:'TSuccess
    | Error of ErrorValue:'TError

// cases are constructed like this:
let success = Ok 123
let failure = Error "This is an error message!"
F# Result type definition and usage

This is how we can construct and call a function that returns Result<>.

let tryParseEmailAddress (email:string) : Result<string, string> =
    if email.Contains "@" then
        Ok email
    else
        Error "Email must contain @"

// usage:
let test1 = tryParseEmailAddress "wrongemail.com"
// val test1 = Error "Email must contain @"

let test2 = tryParseEmailAddress "[email protected]"
// val test2 = Ok "[email protected]"
Probably shouldn't use this in production...

When dealing with Result<>, we need a way of retrieving the values from the cases. This is done using pattern matching.

let parseResult = tryParseEmailAddress "[email protected]"

match parseResult with
| Ok email -> printfn $"Email parsed successfully: %s{email}"
| Error errorMsg -> printfn $"Error: %s{errorMsg}"

// Prints:
// Email parsed successfully: [email protected]
Unwrapping Result<> 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 and ref 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.

let tryValidateUserSignup email ageInput : Result<User, string> =
    match tryParseEmailAddress email with
    | Error errorMsg -> Error errorMsg
    | Ok parsedEmail ->
        if not (tryCheckEmailAddressNotRegistered parsedEmail) then
            Error "Email is already registered"
        else
            match tryParseAge ageInput with
            | Error invalidAgeErrMsg -> Error invalidAgeErrMsg
            | Ok age ->
                if not (checkAgeBool age) then
                    Error "Age too low"
                else
                    // Create a User record. Wrap it inside an Ok case
                    Ok { User.Email = parsedEmail; Age = age }
User sign-up function using Result

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.

let tryUserSignup (email: string) (ageInput: string) : Result<User, string> =
    result {
        let! parsedEmail = tryParseEmailAddress email
        let! age = tryParseAge ageInput
        let! emailInUse = isEmailRegistered parsedEmail

        if emailInUse then
            // Terminate with an error case
            return! Error "Email is already registered"
        else
            // Create and return a User record.
            return { Email = parsedEmail; Age = age }
    }
tryValidateUserSignup using the result {...} computation expression

Here, let! is used in the context of the result {...} block.

result {...} defines the unwrapping as:

  1. The value needs to be of type Result<TSuccess, TError>.
  2. If the value is an Ok<TSuccess> case, get the wrapped TSuccess and bind it.
  3. If the value is an Error<TError> case, get the wrapped TError, abort further processing of the block, and return the error value.
// Call tryUserSignup with an invalid email.
tryUserSignup "wrongEmail" "30"
// Error "Email must contain @" <-- processing terminated early

// Call tryUserSignup with all valid values
tryUserSignup "[email protected]" "30"
// Ok { Email = "[email protected]"; Age = 30 }
Calling tryUserSignup with rejected and passing inputs

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 {...}.

let trySignup emailInput ageInput nameInput : Result<User, string list> =
    validation {
        let! name = tryParseName nameInput
        and! email = tryParseEmailAddress emailInput
        and! age = tryParseAge ageStr

        printfn $"All inputs parsed: {name}, {email}, {age}"
        // continue processing
        // ...
    }

// Usage:
trySignup "wrongEmail" "" ""
// Error ["Name cannot be empty"; "Email must contain @"; "Invalid age"]

trySignup "[email protected]" "30" "John Doe"
// Prints to console: "All inputs parsed: John Doe, [email protected], 30"
If the user supplies multiple incorrect inputs, we want to collect them all, and return them in one response.

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

let insertUser (conn: Db) (user: User): Task<Result<PersistedUser, Error>>
The signature of our asynchronous user persistence function

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.

Compilation error due to type mismatch
Compilation error due to type mismatch: result can't unwrap Tasks

Approach 1: Nesting

result {
    let! lastName = tryParseLastName lastNameInput
    let! email = tryParseEmailAddress email
    
    let user = { User.Email = email; LastName = lastName }
    
    let dbUserAsyncResult = 
        task {
            let! connection = getConnection ()
            let! dbUser = insertUser connection user
            return dbUser
        } 
    // Block on the task. Note: this is the TPL's Task GetResult()
    let dbUserResult = dbUserAsyncResult.GetAwaiter().GetResult()
    
    // this let! unwraps the Result<>
    let! insertedId = dbUserResult
}
We can nest different computation expressions.

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.

let tryUserSignup email lastNameInput =
    taskResult { // <-- this is new
        let! lastName = tryParseLastName lastNameInput
        let! email = tryParseEmailAddress email
        
        let user = { User.Email = email; LastName = lastName }

        let! connection = getConnection ()
        
        // unwraps Task<Result<PersistedUser, Error>> to PersistedUser
        // or terminates the expression block and returns the Error
        let! insertedUser = insertUser connection user
        // ...
    }
`taskResult {...}`'s `let!` knows how to unwrap `Result`-, `Async`- and `Task<Result<,>>`-typed values.

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.

task {
    // Unwrap Task<> using let!
    let! insertedUserResult = insertUser connection user
    // Unwrap Result<> using pattern matching
    match insertedUserResult with
    | Error err -> return err
    | Ok dbUser ->
        // Continue with the successfully persisted user
        // ...
}
We can unwrap the Result<> manually using pattern matching, too.

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 and taskResult 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.

Comments
Comments are powered by GitHub and giscus.

By loading the comments you consent with GitHub's data protection policy.

Your choice will be remembered for your current browser.