A recap of what I've ranted on about so far:

  • Most errors are type errors and would be solved with stronger typing of function parameters (a less interesting problem so I left it there);
  • Most of the remaining errors are type errors would be solved with stronger typing of function ranges (a more interesting problem);
  • out variables do nothing to encourage better behaviour, and following the 'errors are values' format of languages like Go is "basically" equivalent (and thus no help either);
  • A Result type that hides the return value and forces you to handle it in a defensive (and optimistic) manner is the easiest way to guarantee correct handling of errors, but the resulting code is gross;
  • async/await can be hacked to work with the type and emulate its behaviour to allow seamless error handling in the background, but there's a lot of functionality missing.

Some of the missing functionality from the async solution is simple to overcome. One example was how to conditionally fall into a failure condition inside a block, which is as simple as calling await on a specifically constructed failure result:

async BaseResult<string> UnnecessaryContextInFunctionName()
{
  var stuff = await GetListOfStuff();
  if (!stuff.Any())
  {
    await new FailureResult<string>(new BaseErrorMessage("no stuff"));
  }
  
  return stuff.Take(5)
    .Select(s => s.Summary)
    .Join(" and ");
}

It can be cleaned further by something like a Guard class with a function that takes a condition and an error to default to should the condition fail:

async BaseResult<string> UnnecessaryContextInFunctionName()
{
  var stuff = await GetListOfStuff();
  
  await Guard.Require(stuff.Any(),
    new BaseErrorMessage("no stuff"));
  
  return stuff.Take(5)
    .Select(s => s.Summary)
    .Join(" and ");
}

The Result type is already surprisingly powerful, and requires very few extra tweaks before it can replace most catch blocks and if statements.


Now that we're using async, the Bind function is redundant (as are the Transform and Then functions). We aren't, however, in a position to deal with any errors that we may want to recover from. Let's replace Bind with a new function, BindError, that will allow us to possibly recover from an error.

Result<T, TNErr> BindError(Func<TErr, Result<T, TNErr>> binder);

Note that, instead of dealing with a manipulation on success values, we are manipulating an error value into a completely new Result - one that may be in a success state if we wish. Curiously, this allows us to think of error recovery as simply the inverse of dangerous data manipulation: failing, say, a division because of a zero denominator is the inverse of choosing a default value from the error that results from the failed division.

Because the async blocks for this type 'fail fast' (in the sense that they break early should they await a failed result), we would need to do this bind before we await the expression: it would thus look very similar to the Catch calls in earlier examples.

The true power of this function lies in the extra functions we can define off the back of it. One common use case for a function like this would be to check if a given Result<T, TErr> happens to hold an error of type TSErr where TSErr : TErr and manipulate accordingly:

Result<T, TErr> BindErrorOf<T, TErr, TSErr>(this Result<T, TErr> result,
  Func<TSErr, Result<T, TErr>> binder)
    where TSErr : TErr =>
      result.BindError(err =>
        err is TSErr sErr ? binder(sErr) : result);

Notice that even this function doesn't need to care if we're in a success state or not, because it's defined as an extension method.

Here's another extension method for simply defaulting the value for any error.

Result<T, TErr> OrDefault<T, TErr>(this Result<T, TErr> result,
  T defaultValue) =>
      result.BindError(_ => new SuccessResult<T, TErr>(defaultValue));

Calling any of these functions on a Result before it is awaited allow full recovery from any error or subset of errors. Note, however, that the OrDefault function above cannot replace the DefaultIfEmpty function defined on Result itself: a bind can do anything except unwrap the underlying value.


As moaned about before, async types don't support more than one type parameter: the async block needs to know exactly what type should be returned from it, and adding more generic type parameters would leave it mighty confused. For our current purposes, it will suffice to assume we have a different Result type for each error types, and a corresponding AsyncMethodBuilder for each. We know that we can await any awaitable expression inside an async block regardless of return type, meaning we can await Results with different error types. The current code in our builder type, however, isn't equipped to deal with that.

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();
  }
}

This is code from before (with the exception block gone because who cares) where we blindly assume the equivalence of awaited expression and return type error types. We could amend this by instead using a switch expression to convert various expression types to the one we plan to return (should such a conversion be necessary) and throw an exception if the cast fails.

I must say: this isn't great. C# isn't going to let us break at compile time if the types don't match, and we're putting a lot of extra logic into a builder type that ideally shouldn't ever need to be seen. There is a silver lining though: if it breaks and we need to throw an Exception, that exception will have a very clear explanation for what went wrong. Should we fail a conversion and need to throw, the created exception would be a meta-exception, able to detail exactly which error conversion wasn't covered and the unhandled error itself. The barely conscious developer at least knows exactly what to fix.

The idea of writing 'error converters' into types and throwing for any gaps appears like an awfully large amount of work, and it is, but I argue the alternatives are worse. A common example I run into is one where project A depends on project B which depends on project C, and exceptions thrown in the logic of project C bubble past B to be dealt with by A. This is fine until project B swaps out its dependency for a new project D, leaving A with a dependency on C that is neither necessary nor able to handle the exceptions that might be thrown by project D. If we were to use exceptions as proper panics, the types they have would no longer be relevant so there would be little need to catch them; if we're using exceptions to cover foreseeable cases then we're in trouble, and ought to declare proper logic for converting different errors from different sources.

A very basic example of the new AwaitOnCompleted code:

public void AwaitOnCompleted<TAwaiter, TStateMachine>(
  ref TAwaiter awaiter, ref TStateMachine stateMachine)
  where TAwaiter : INotifyCompletion
  where TStateMachine : IAsyncStateMachine
{
  _err = awaiter switch
  {
    IAwaitResultErrorHolder<BaseError> e => e.GetError(),
    IAwaitResultErrorHolder<LookupError> e => new BaseErrorMessage(e.GetError().Message),
    _ => throw new InvalidAsynchronousStateException(
      "Unable to pull error information about error from underlying type - are you" +
      " awaiting another Type besides BaseResult?")
  };
}

This converter demonstrates how to flatten complex error types into a simple single message, but a more useful converter might copy over all the information possible; the resulting error type for the async function return type would be as complex as any error the code inside could produce, meaning no required information would be lost.

This method also has the useful consequence of making explicit the idea that, as you bubble up the stack, your error types get considerably heavier if there isn't a way to either recover from errors or find commonalities in the properties of different errors. The process of driving down the complexity of error types is exactly the same process as making sense of all the different ways a process can fail, and figuring out how to represent those in a sensible manner.

Obviously, the code I normally work with doesn't do anything like this. The consequence is clear: I have no way to decipher the various error states I might be in at any time, and I don't even have the guarantee that an update to a dependency won't completely alter the state of play (and this is all true even for well-managed and well-documented codebases). My code is therefore perfectly capable of throwing exceptions, but it's rare for an exception to actually tell me what I need to know.