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();
}
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();
}
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
- As you've probably guessed,
validateRequest
,canonicalizeEmail
, and the wholechangeEmailWorkflow
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
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
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)
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)
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
}
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
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 ofdivide 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
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
You might notice that
let canonicalized = canonicalizeEmail validated
is the only line that uses a regular version oflet
instead oflet!
. That's because it returnsChangeEmailCommand
rather thanResult<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
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> =
// ...
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>
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
Let's compare it to the original F# example:
let changeEmail =
validateRequest
>> canonicalizeEmail
>> updateDbFromRequest
>> sendEmail
>> returnMessage
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)
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)