In this article I'm going to attempt to leverage syntactic sugar in C# (specifically the async-await pattern) to refactor my code into something much simpler, but still as powerful.
To put it simply, C# has a very simple version of Haskell's do-notation, that (by virtue of a novel implementation method) only really works for Task
types, but can be hacked to work with a Maybe
or Either
type. Apologies in advance: this is complicated, and I am not good enough at writing to make it any simpler.
It can be implemented with async
and await
. These are terrible keyword choices for this functionality. Both words reference the fact that a Task
normally involves waiting, yet neither relates to or requires this behaviour at all.
async
is a fascinating keyword. It has a huge amount of power over the execution of the accompanying function body. Only particular types in C# can be return types for async
blocks, and we can create one of these types by annotating the return type with our own AsyncMethodBuilder
type - a new class that tells C# what to do with the function body. A valid AsyncMethodBuilder
type must follow a particular interface, but Microsoft didn't bother to actually write one: we must instead just define particular functions with particular signatures and we'll only know we did it right if the code compiles. The required contract can be found here. I've implemented that contract in a new type called ResultMethodBuilder<T>
.
[AsyncMethodBuilder(typeof(ResultMethodBuilder<>))]
public abstract class BaseResult<T> : Result<T, BaseError>
{
...
}
With this annotation, BaseResult
is now what the literature likes to call a 'Task-like' type. It's not — it has nothing at all in common with tasks. Because it's Task-like, it can be the return type to an async
function. There's one small (but actually quite large) limitation on this: your AsyncMethodBuilder
can have at most one generic type parameters. To get around this, I've had to create a BaseError
type and fix that as my error type (BaseError
is implemented by BaseException
(constructed with an Exception
) or a BaseErrorMessage
(constructed with a string
)).
Because we've got a new abstract
class to stand in for Result
, we'll need to change our SuccessResult
and FailureResult
to subclass this new BaseResult
.
Now that it's async
(reminder: it isn't actually asynchronous), it's time to make it awaitable (that is to say, something I can use with the await
keyword: I'm not going to actually wait for it to complete because it's not a task). Given the complete absence of documentation for Task-like types, it's only expected that C# documented this bit very well. The new contract required on BaseResult
is pretty simple, but requires the creation of a new type AwaitResult
(which acts as the 'awaiter' object for a BaseResult
).
[AsyncMethodBuilder(typeof(ResultMethodBuilder<>))]
public abstract class BaseResult<T> : IBaseResult<T, BaseError>
{
...
public abstract IAwaitResult<T, BaseError> GetAwaiter();
}
The code for the AwaitResult
is long but also simple.
public interface IAwaitResultErrorHolder<out TErr> : ICriticalNotifyCompletion
{
TErr GetError();
}
public interface IAwaitResult<out T, out TErr> : IAwaitResultErrorHolder<out TErr>
{
bool IsCompleted { get; }
T GetResult();
}
public readonly struct SuccessAwaitResult<T, TErr> : IAwaitResult<T, TErr>
{
private readonly T _item;
public SuccessAwaitResult(T item) => _item = item;
public void OnCompleted(Action continuation) => continuation();
public void UnsafeOnCompleted(Action continuation) => continuation();
public bool IsCompleted => true;
public T GetResult() => _item;
public TErr GetError() => throw new InvalidOperationException();
}
public readonly struct FailureAwaitResult<T, TErr> : IAwaitResult<T, TErr>
{
private readonly TErr _err;
public FailureAwaitResult(TErr err) => _err = err;
public void OnCompleted(Action continuation)
{
}
public void UnsafeOnCompleted(Action continuation)
{
}
public bool IsCompleted => false;
public T GetResult() => throw new InvalidOperationException();
public TErr GetError() => _err;
}
The BaseResult
types construct the relevant AwaitResult
when GetAwaiter
is called. The IAwaitResultErrorHolder
will be important later for matching all AwaitResult
s using the is
keyword (it's nearly impossible to determine its type without this).
Why on Earth am I doing this? There's an interesting line in the documentation:
"The purpose of the IsCompleted
property is to determine if the task is already complete. If so, there is no need to suspend evaluation."
This is as close as the documentation will ever get to acknowledging that the await
keyword has far more to do with success and failure than it has to do with waiting. Awaitable things are either complete or incomplete. By chance, C# also gives developers the opportunity to "wait" for things to be completed, hence the support for Task
s. Our Result
types are also either successful or unsuccessful (with the added simplicity that we never need to wait), so we can reuse the pattern easily.
Now we have an "awaitable" "asynchronous" type (reminder: it's still neither) but we haven't yet described how C# should actually handle the various events that might occur in the function body. That's where the ResultMethodBuilder
comes in. This single type handles every event in the async
block.
I've split its definition into chunks. This is the first:
public sealed class ResultMethodBuilder<T>
{
private T _result = default!;
private BaseError _err = default!;
private bool _hasResult;
public static ResultMethodBuilder<T> Create() =>
new ResultMethodBuilder<T>();
public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine =>
stateMachine.MoveNext();
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
}
...
The instance is created using Create
and started with Start
— the logic here isn't relevant for our purposes. Before the async
block completes, it is guaranteed to have populated either the _result
or _err
variable and accordingly set _hasResult
, so I am safe to use default!
as an initial assignment.
...
public void SetException(Exception exception) =>
_err = new BaseException(ExceptionDispatchInfo.Capture(exception));
public void SetResult(T result)
{
_result = result;
_hasResult = true;
}
...
If an exception is thrown during the evaluation of any expression, it's automatically caught and passed to SetException
. The rest of the code in the block is then skipped. If all code in the block completes successfully, the contents of the return
expression are passed to SetResult
.
...
public void AwaitOnCompleted<TAwaiter, TStateMachine>(
ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (awaiter is IAwaitResultErrorHolder<BaseError> baseErrorHolder)
{
_err = baseErrorHolder.GetError();
}
else
{
throw new InvalidAsynchronousStateException(
"Unable to pull error information about error from underlying type - are you" +
" awaiting another Type besides BaseResult?");
}
}
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (awaiter is IAwaitResultErrorHolder<BaseError> baseErrorHolder)
{
_err = baseErrorHolder.GetError();
}
else
{
throw new InvalidAsynchronousStateException(
"Unable to pull error information about error from underlying type - are you" +
" awaiting another Type besides BaseResult?");
}
}
...
These functions are called when the awaited expression isn't (yet) complete. The TAwaiter
type, whatever it is, implements INotifyCompletion
, so we can pass an action to OnCompleted
if we like. In the case of Task
, this function will call this function with an action to advance the state of the passed in state machine, so that computation of the block continues once the awaited Task
completes. This is why only awaited tasks block execution: AwaitOnCompleted
isn't called for unawaited objects.
I did implement OnCompleted
in my AwaitResult
type, but I didn't need to because I don't need to ever call it: an incomplete BaseResult
will remain so forever. I therefore don't need to advance the state machine; in doing so I end execution of the block at that statement automatically if it's incomplete. I then attempt a cast to an IAwaitResultErrorHolder
so I can pull out and preserve the relevant error.
Why do I need the common interface IAwaitResultErrorHolder
? C#'s type system, wonderful though it is, can't match IAwaitResult<T>
for all T
. Any function I would want to call on any IAwaitResult<T>
must be a function indepedent of its generic type parameter so I can create an interface to capture the generic-type-agnostic function (in this case GetError()
).
Why do we need to throw the exception when this type cast fails? C# doesn't actually complain if you decide to await a Task
inside an async BaseResult<T>
block, so we should crash if anyone tries: we've removed any logic to do with waiting so we can't properly await Task
s in these new async
blocks. A more complete version of this code would persist all Task
-specific functionality instead of throwing an exception.
...
public BaseResult<T> Task =>
_hasResult
? new SuccessBaseResult<T>(_result)
: (BaseResult<T>) new FailureBaseResult<T>(_err);
} // end of class
It doesn't really make sense to call this property Task
(again, we're not implementing Task
). It's called at the end of the function block to instantiate the object that will actually be returned from the async
block. Note that we will either have an error saved (either from awaiting a Result
in a failure state or a thrown exception) or a successful result (returned by the async
block) so can construct a single BaseResult
with all the information we need.
(An extra complication: note that if we happened to throw an InvalidAsynchronousStateException
above, that exception would bubble outside this code block because it was thrown inside the builder type instead of in the code block managed by said builder type.)
With all of this code in place, async BaseResult
blocks now have a huge amount of implicit control over the code within them. They automatically catch exceptions thrown in the block and let you craft a return object from them. They automatically end execution if an awaited BaseResult
isn't in a success state so further steps are skipped. They automatically pull the error information from any failed awaits and preserve it in the block's returned BaseResult
. They automatically unwrap successful values for use in the function, and automatically wrap those results back up after the block. We have essentially re-implemented bind
, but more.
Let's tweak our Result
-based dictionary lookup and list searching functions.
BaseResult<TValue> GetValue(this Dictionary<TKey, TValue> d, TKey key);
BaseResult<TValue> Find(this IList<TValue> l, Predicate<TValue> predicate);
Now let's define a basic block that looks up numbers in a dictionary and then adjusts the result.
private static readonly Dictionary<string, int> NumberWords =
new Dictionary<string, int>
{
["first"] = 1,
["second"] = 2,
["third"] = 3,
...
};
public async BaseResult<int> GetArrayIndex(string indexAsWord)
{
var index = await NumberWords.GetValue(indexAsWord);
return index - 1;
}
Recall the messy function from the last post.
Result<ProcessStatus, LookupError> GetStatus(User user, Guid id) =>
_usersToProcesses.GetValue(user)
.Catch(_ => _logger.Info($"No processes for user '{user}'"))
.Bind(processes => processes.Find(p => p.Id == id))
.Catch(_ => _logger.Info(
$"No process with id '{id}' found for user '{user}'"))
.Transform(p => p.Status);
What does that look like using BaseResult
?
async BaseResult<ProcessStatus> GetStatus(User user, Guid id)
{
var userProcesses = await _usersToProcesses.GetValue(user)
.Catch(_ => _logger.Info($"No processes for user '{user}'"));
var process = await userProcesses.Find(p => p.Id == id)
.Catch(_ => _logger.Info(
$"No process with id '{id}' found for user '{user}'"));
return process.Status;
}
The only difference between these code blocks is that the second block won't log the second error if the first error has been logged, while the first one will. Hopefully it's now obvious how powerful these keywords are, and how inappropriate the choices for their names are: nothing here is async
, yet I wouldn't consider this a gross abuse of the functionality either.
There are many questions to answer still. How do I return a failure result inside an async
block, given that its return type is T
and not BaseResult<T>
? How can I attempt recovery if a lookup fails?
I'll figure out how to fix this later.