Blog Logo
TAGS

Abusing await with a result type to achieve rust-like error propagation in C#

Rust allows you to propagate errors automatically with the ? keyword, short-circuiting the methods execution and returning the error. Previously, we attempted to modify the IL to propagate errors with Fody, turning this: public Result<int> MultiplyBy2() { var result = GetNumber().OrReturn(); return Result<int>.Success(result * 2); } Into this: public Result<int> MultiplyBy2() { var temp = GetNumber(); if (temp.IsError) { return temp; } var result = temp.Value; return Result<int>.Success(result * 2); } It worked! Sort of, turns out C# is complex and trying to modify the IL will be rabbit hole we may never escape from. but is there a way we can have the compiler deal with all the complexity for us? A friend pointed out another even more bonkers idea. await there one second... The await operator suspends execution of the enclosing function until the asynchronous operation is completed. To do this under the hood it generates a state machine. Im not going to begin to try to explain this because 1) I dont know enough, 2) Stephen Toub exists. What we will end up with is this: Result<double> Parse(string input) => Result.Try(() => double.Parse(input)); Result<double> Divide(double x, double y) => Result.Try(() => x / y); async Result<double> Do(string a, string b) { var x = await Parse(a); var y = await Parse(b); Console.WriteLine(Successfully parsed inputs); return await Divide(x, y); } // Usage Console.WriteLine(Do(2, b)); // Will display the error from Parse(b) The key to achieving this is that we can control how that state machine behaves by creating a custom AsyncMethodBuilder, allowing us to short-circuit the method execution when it encounters an error result. [AsyncMethodBuilder(typeof(ResultAsyncMethodBuilder<>))] public struct Result<T> {} The full code: [AsyncMethodBuilder(typeof(ResultAsyncMethodBuilder<>))] public struct Result<T> { private Result(T value) { IsSuccess = true; Value = value; Error = null; } private Result(Exception error) { IsSuccess = false; Value = default; Error = error; } [MemberNotNullWhen(true, nameof(Value))][MemberNotNullWhen(false, nameof(Error))] public bool IsSuccess { get; } public T? Value { get; } public Exception? Error { get; } public static Result<T> Success(T value) => new(value); public static Result<T> Fail(Exception error) => new(error); public static implicit operator Result<T>(T value) => Success(value); public ResultAwaiter<T> GetAwaiter() => new ResultAwaiter<T>(this); public override string ToString() => IsSuccess ? Value?.ToString() : Error: {Error?.Message}; } public static class Result { public static Result<T> Try<T>(Func<T> function) { try { return Result<T>.Success(function()); } catch (Exception ex) { return Result<T>.Fail(ex); } } } public struct ResultAwaiter<T> : ICriticalNotifyCompletion { private readonly Result<T> _result; public ResultAwaiter(Result<T> result) { _result = result; } public bool IsCompleted => true; public T GetResult() { if (!_result.IsSuccess) ResultAsyncMethodBuilder<> }