DEV Community

Cover image for Computation Expressions: an F# feature I wish I had but that will never be implemented in C#
max-arshinov
max-arshinov

Posted on

Computation Expressions: an F# feature I wish I had but that will never be implemented in C#

Seasoned developers like myself sometimes invariably stumble upon charming paradigms in one language that evokes longing when coding in the other. One spellbinding feature from the F# toolkit is F# Computation Expressions. While we can dream, it’s a feature unlikely to find a home in C#.

Introducing a Use Case

To scrutinize what’s at stake, let's dissect an example inspired by my previous article, which highlights two irksome aspects of C#:

public async Task<IActionResult> ChangeEmail(int userId, string newEmail)
{
    User existingUser = await _userRepository.GetByEmailAsync(newEmail);
    if (existingUser != null && existingUser.Id != userId)
    {
        return BadRequest();
    }

    User user = await _userRepository.GetByIdAsync(userId);
    newEmail = CanonicalizeEmail(newEmail);
    user.ChangeEmail(newEmail);
    await _userRepository.SaveAsync(user);
    await _notifications.SendAsync(user);
    return Ok();
}
Enter fullscreen mode Exit fullscreen mode

The DRY Challenge

The code snippet below tends to be repetitive. DRY (Don't Repeat Yourself) becomes a hurdle when navigating through the asynchronous verification mechanism in C#.

User existingUser = await _userRepository.GetByEmailAsync(newEmail);
if (existingUser != null && existingUser.Id != userId)
{
    return BadRequest();
}
Enter fullscreen mode Exit fullscreen mode

The “async/await” Predicament

The Task keyword dictates an unavoidable cascade of "async all the way" down your call stack.

Though neither of these issues critically undermines code maintainability, they can ruffle the feathers of developers striving for elegant code, especially those involved in code reviews.

Introducing F# to the Stage

F# provides a splendidly terse and comprehensible alternative. Don't worry if you're not familiar with the syntax as I only want to compare the readability of the C# and the F# versions:

let changeEmail =
    validateRequest
    >> canonicalizeEmail
    >> updateDbFromRequest
    >> sendEmail
    >> returnMessage
Enter fullscreen mode Exit fullscreen mode
  • As you've probably guessed, validateRequest, canonicalizeEmail, and the whole changeEmailWorkflow are just functions.
  • The composition operator >> takes two functions and returns a function.

Visual aids borrowed from Scott Wlaschin's Railway Oriented Programming illustrate this point wonderfully.

Even those who are unfamiliar with the syntax can read and understand it. In fact, you can even show this code to non-technical people and most likely they will be able to read and understand the snippet. When reformulated in a less idiomatic F# style, an extra layer of verbosity and complexity becomes apparent:

let changeEmailWithLet (request: ChangeEmailCommand) =
    let validated = validateRequest request 
    let canonicalized = canonicalizeEmail validated
    let updated = updateDbFromRequest canonicalized
    let sent = sendEmail updated
    returnMessage sent
Enter fullscreen mode Exit fullscreen mode

Despite this, it still maintains arguably better readability than its C# counterpart. Regardless, this transformation is essential for understanding the main concept of this article.

Unraveling Computation Expressions

F#'s Computation expressions furnish developers with a syntax that allows computations to be sequenced and combined in a delightful manner. But let's shift from definition to practice, with a result computation as an example:

Result

The Result<'T,'TError> type lets you write error-tolerant code that can be composed.

// The definition of Result in FSharp.Core
type Result<'T,'TError> =
    | Ok of ResultValue:'T
    | Error of ErrorValue:'TError
Enter fullscreen mode Exit fullscreen mode

This function employs the Result<'T,'TError> type to yield an Ok or Error return.

let divide x y = 
    if y = 0.0 then Error "Cannot divide by zero"
    else Ok (x / y)
Enter fullscreen mode Exit fullscreen mode

The caller has to match the returning value and check if it's an Ok or an Error case. This kind of checking might be tedious and messy when we need to chain several division operations like on the code snippet below:

let compute a b c d =
    match divide a b with
    | Error e -> Error e
    | Ok division ->
        match divide c d with
        | Error e -> Error e
        | Ok anotherDivision -> Ok (division + anotherDivision)
Enter fullscreen mode Exit fullscreen mode

When chain-linked through various operations, the simplicity and efficacy of computation expressions shine. Instead of nesting match/with expressions, we can wrap divisions in the result {} context so we can use let! and return:

let result = ResultBuilder()

let compute a b c d =
    result {
        let! division = divide a b
        let! anotherDivision = divide c d
        return division + anotherDivision
    }
Enter fullscreen mode Exit fullscreen mode

The code within the result {} context is a computation expression. The programmer is free to define the semantics of the context. In our case we want to continue execution on Ok and skip execution on Error.

type ResultBuilder() =
    member _.Bind(x, f) =
        match x with
        | Ok v -> f v
        | Error e -> Error e
    member _.Return(x) = Ok x
Enter fullscreen mode Exit fullscreen mode

The Bind function enables the let! keyword. It "unwraps" the Result<'T, string> value by chaining the following results of the computation expression.

  • x is the result of divide a b
  • f is the remaining part of the computation that depends on unwrapped value of x:
let! anotherDivision = divide c d
return division + anotherDivision
Enter fullscreen mode Exit fullscreen mode

The Return function enables the return keyword so the whole computation still returns Result<'T, string>. This is why Result<'T, string> is defined as a way to write error-tolerant code that can be composed.

Validation

Once written, the ResultBuilder can be applied to any workflow that works with any Result<'T,'Terror> type. Let's apply it to the original "Update Email" use-case:

type SimpleResult<'T> = Result<'T, string> // It can be any error type. String is for simpicity reasons only.

let changeEmail request =
    result {
        let! validated = validateRequest request // unwraps Result
        let canonicalized = canonicalizeEmail validated
        let! updated = updateDbFromRequest canonicalized // unwraps Result
        let! sent = sendEmail updated // unwraps Result
        return sent // wraps the result
    } |> returnMessage // SimpleResult<ChangeEmailCommand> -> IActionResult
Enter fullscreen mode Exit fullscreen mode

You might notice that let canonicalized = canonicalizeEmail validated is the only line that uses a regular version of let instead of let!. That's because it returns ChangeEmailCommand rather than Result<ChangeEmailCommand, 'TError>. There is no need to unwrap the returning value. It wasn't wrapped because the function can't fail.

Compare it to the previous version. I only slightly updated the last line with the forward pipe |> operator to make it consistent with the computation-expressions-based version of the code:

let changeEmailWithLet request =
    let validated = validateRequest request
    let canonicalized = canonicalizeEmail validated
    let updated = updateDbFromRequest canonicalized
    let sent = sendEmail updated
    sent |> returnMessage
Enter fullscreen mode Exit fullscreen mode

It's not very different, right? The beauty of computation expressions is that they are a generic concept. They are not limited to Result<'T, 'TError>. For instance, Task<'T> has built-in support of computation expressions.

Blending Asynchronicity and Validation

Moreover, it's possible to build a computation expression that supports both error handling and asynchronicity:

type TaskResult<'T> = Task<SimpleResult<'T>>

type TaskResultBuilder<'T>() =
    member _.Bind(x: SimpleResult<'T>, f: 'T -> TaskResult<'U>) : TaskResult<'U> =
        // ...
    member _.Bind(x: TaskResult<'T>, f: 'T -> TaskResult<'U>) : TaskResult<'U> =
        // ...

    member _.Return(x: 'T) : TaskResult<'T> = 
        // ...

    member _.Return(x: SimpleResult<'T>) : TaskResult<'T> = 
        // ...
Enter fullscreen mode Exit fullscreen mode

You can find the implementation details in Mark Seemann's Task asynchronous programming as an IO surrogate.

The code inside remains the same despite all functions are now asynchronous. We are now using the enhanced taskResult computation expression that supports the Task<Resut<'T, 'TError> type.

let changeEmail request =
    taskResult {
        let! validated = validateRequest request // unwraps TaskResult
        let canonicalized = canonicalizeEmail validated
        let! updated = updateDbFromRequest canonicalized // unwraps TaskResult
        let! sent = sendEmail updated // unwraps Result
        return sent // wraps TaskResult
    } |> returnMessage // TaskResult<ChangeEmailCommand> -> Task<IActionResult>
Enter fullscreen mode Exit fullscreen mode

Note that despite the let! sent = sendEmail updated // ChangeEmailCommand -> SimpleResult<ChangeEmailCommand> type definition lacks asynchronicity we can still use let! thanks to the overloaded implementation of Bind from the TaskResultBuilder<'T> definition above.

Operator Overloading

Last but not least. The code example above is still verbose. All these parameters we had to add to make let! work ruin the beauty of the original example. The good news is that F# supports operator overloading! There is even a well-known convention for monadic compositions. It's called "Kleisli composition" or just "fish operator": >=>. Let's define the Kleisli composition operator and two additional operators that might be handy:

  • >=> : composes functions that are both asynchronous and can fail.
  • >>> : composes asynchronous functions that can fail and simple functions that can't fail.
  • >?> : composes asynchronous functions that can fail and synchronous functions that can fail.
let changeEmailKleisli =
    validateRequest // 'T -> TaskResult<'T>
    >>> canonicalizeEmail // canonicalizeEmail is synchronous and always successful
    >=> updateDbFromRequest // updateDbFromRequest is asynchronous and can fail
    >?> sendEmail // sendEmail is synchronous and can fail
    >> returnMessage // return message just translates the Result type to IActionResult
Enter fullscreen mode Exit fullscreen mode

Let's compare it to the original F# example:

let changeEmail =
    validateRequest
    >> canonicalizeEmail
    >> updateDbFromRequest
    >> sendEmail
    >> returnMessage
Enter fullscreen mode Exit fullscreen mode

There is indeed some minor difference: the original example only uses >> while the latter version leverages more advanced composition types: >>, >=>, >>>, >?>. These operators might be confusing at first. However, in the long run, I like that the operators are different. As a code reviewer, I might be interested in what kind of composition. Different operators make it clear that the final workflow consists of functions with different signatures.

Limitations of the Parameterless Version

Alas, despite being review-friendly, the last variant is not without drawbacks. I can name two major ones.

Debugging experience

Whichever composition >>, >=>, >>>, >?> is used, there is no way to set a breakpoint on a junction side. Thus, to debug the composed function one needs to add breakpoints inside of the bodies of the composed function. This is the price one has to pay for getting rid of let keywords and parameters. Arguably, this is a major tradeoff between two maintainability aspects.

Operators are Stateless

Until now, all our builders had parameterless constructors. However, they don't have to. Should we need to add logging support, only one modification of Bind is required. Unlike Scala, there is no straightforward way to inject a dependency into an operator in F# ... Maybe it's a good thing, though.

type TaskResultBuilder<'T>(logger: ILogger<'T>) =
    member _.Bind(x: TaskResult<'T>, f: 'T -> TaskResult<'U>) : TaskResult<'U> =
    // add logging here...
    // it will trigger on every let!

//...

let taskResult = TaskResultBuilder(logger)
Enter fullscreen mode Exit fullscreen mode

Why This Feature Will Never Be Implemented in C#

C# follows a completely different design approach: async/await, yield, LINQ, all these quality-of-life features. While F# gives developers powerful but sometimes too generic and not always easy-to-grasp abstractions, C# uses specific and somewhat opinionated (in a good way) tools that do just one thing but do it great. The difference in these two approaches to language design might be as significant as the OOP/Functional-first natures of C# and F# correspondingly.

While there are ways to implement weird things by abusing async/await keywords or the SelectMany method, these are at most amusing exercises rather than production-ready solutions.

Conclusion

The elegance and conciseness of F# offer undeniable allure, particularly when contrasted against specific use cases in C#. While F# bestows a straightforward approach to managing asynchronicity and validation, C# developers can only peer into this functional world with a hint of envy.

Top comments (0)