Post

A Result type for C#

I was recently thinking about optional/result types and how C# doesn’t have them natively. I thought I would have a go at implementing a result type myself as a challenge, because I hadn’t written any C# for a while. I chose result types specifically because C# has T? for optional types (which aren’t perfect) and there are already good functional type libraries out there with optional type implementations.

The end result was at least a little interesting, so I thought I’d write a quick post about it!

What is a result type?

A result type is a function return value that contains either an ‘Ok’ value or an error value. Which value the instance actually holds can be checked at runtime.

Instances of this type are returned from functions where the operation may fail. The caller of the function can then check the result and either continue execution or handle the error case appropriately. Such types often provide method chaining to allow for fluent error handling, which can improve code readability with complex error handling logic.

Many languages support these types in their standard library: it is one of the standard mechanisms of error handling in Rust and has been added to C++23, for example. Small aside: I think the choice of name for std::expected in C++ leaves a bit to be desired - what if I expect there to be an error?

What about exceptions?

In C#, exceptions are the standard mechanism for error handling. However, in my opinion, exceptions suffer from a few undesirable properties:

  • Exceptions are side effects.
  • Often, exceptions are thrown for non-exceptional behaviour (e.g. file not found, collection doesn’t contain an element, etc.).
  • Throwing an exception is analagous to a goto to wherever the closest exception handler is.
    • In library code or large codebases, you might not even know where this is.
    • It might be so far away that any chance at local reasoning or correcting the issue is destroyed.
  • Exceptions can encourage lazy design idioms like log-and-throw patterns to report errors, rather than actually dealing with them.
  • When an exception is thrown, the stack will usually unwind which will cause the destruction of every object in the path of the unwinding.
    • In GC languages, you may have non-deterministic behaviour as the GC reclaims an unknown amount of objects.
    • In any other language with exceptions, the objects you destroy may be expensive or potentially unsafe to recreate.
  • They are a language-level mechanism for error handling. Once you use that specific mechanism, your consumers are forced to use it too.

With all that said, there are definitely plenty of good reasons to use exceptions. Sometimes, things do go unexpectedly wrong in ways that are nontrivial that resolve. Other error handling methods often fall back to some kind of exception/panic/abort if they completely fail anyway.

This post does not aim to be a comprehensive overview of the merits of each error handling method. The point is that, like many things, it’s good to have options to be able to apply the right tool for the job.

Implementing Result in C#

Let’s start by defining what we will want from our result type:

  • Generic in terms of the result and error type.
  • Can return either value.
  • Immutable.
  • Can be queried for its ok/error state.
  • Match operation that allows us to switch on the internal state of the result.
  • Monadic operations that allow us to fluently compose many operations that use results.
  • Low overhead to use in real code, and easy to integrate with existing code.

Let’s take these requirements apart bit-by-bit and go through the implementation!

Note: I have borrowed very heavily from the Rust std::result API.

Discriminated Union

One way to implement the data storage for a result type is to use a discriminated union because the value can only ever be an instance of the result type or error type. C# does not have a native discriminated union type, which could make this implementing a bit trickier than one would expect.

Thankfully, the OneOf library provides this functionality for us. One killer feature that will make this implementation really slick is implicit conversion from the each of the union types (assuming it is not ambiguous). This will allow us to return natural expressions from functions, have them implicitly converted to our result type, and this massively reduce burden on consumers of this type.

1
2
3
4
public readonly struct Result<TResult, TError>
{
    private readonly OneOf<TResult, TError> _result;
}

By leveraging this library, we will be able to make the rest of implementation extremely simple because it supports nearly every operation we need for a result type out of the box. In fact, the _result field ticks most of our requirements already!

Creating Result Instances

One clear way to create results is to explicitly state intent by writing the state of the result at the point of construction. This entails static factory ‘constructors’ for both states of the result. Of course, we will need some private constructors to support this.

1
2
3
4
5
6
7
8
9
10
11
public readonly struct Result<TResult, TError>
{
    private readonly OneOf<TResult, TError> _result;
    
    private Result(TResult result) => _result = result;
    private Result(TError error) => _result = error;

    public static Result<TResult, TError> Ok(TResult result) => new(result);
    
    public static Result<TResult, TError> Error(TError error) => new(error);
}

In general, I have come to really like this pattern of named functions to create instances. The clear intent contributes a lot to code readability in my opinion. Let’s look at this in action with an extremely contrived example…

1
2
3
4
5
6
7
8
9
Result<int, string> SquareString(string number)
{
    if (int.TryParse(number, out var x))
    {
        return Result<int, string>.Ok(x * x);
    }
    
    return Result<int, string>.Error("Number must be a valid integer");
}

Yuck!

Whilst the intent is clear, this is extremely long winded and contains a lot of ceremony (although I don’t think this would be so bad if the generic parameters were inferred). One of our goals was to make this type low overhead and easy to adapt to real code and this is certainly not it! I think I would rather fall back on these methods as a last resort.

Thankfully, we can use OneOf’s slick implicit operators as discussed earlier. All we need to do is create our own implicit operators which forward to our factory functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public readonly struct Result<TResult, TError>
{
    private readonly OneOf<TResult, TError> _result;
    
    private Result(TResult result) => _result = result;
    private Result(TError error) => _result = error;

    public static Result<TResult, TError> Ok(TResult result) => new(result);
    
    public static Result<TResult, TError> Error(TError error) => new(error);

    public static implicit operator Result<TResult, TError>(TResult result) => Ok(result);
    
    public static implicit operator Result<TResult, TError>(TError error) => Error(error);
}

And now we can really tidy up our contrived example:

1
2
3
4
5
6
7
8
9
Result<int, string> SquareString(string number)
{
    if (int.TryParse(number, out var x))
    {
        return x * x;
    }
    
    return "Number must be a valid integer";
}

Nice! So now we have our generic, immutable type that we can neatly construct from both ok and error values. If we try to call this function, we will see the following:

1
2
3
4
var okSimple = SquareString("5");
// okSimple = Ok(25)
var errorSimple = SquareString("five");
// errorSimple = Error("Number must be a valid integer")

Now we can turn our attention to consuming these results.

Access, Querying, and Matching

The most basic operation on the result is to query whether we have set the result or error value:

1
2
public bool IsOk => _result.IsT0;
public bool IsError => _result.IsT1;

Let’s also implement the Rust-style accessors for the values. We will want to be able to access the value (perhaps with an error message if the object is not in the expected state), provide default values, alternative functions, or use language-level defaults.

Unfortunately, this is all a bit boilerplate-y…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public TResult Expect(string message) => _result.Match(
    value => value,
    _ => throw new InvalidOperationException(message));
    
public TError ExpectError(string message) => _result.Match(
    _ => throw new InvalidOperationException(message),
    error => error);

public TResult Unwrap() => _result.AsT0;

public TError UnwrapError() => _result.AsT1;
    
public TResult UnwrapOr(TResult defaultValue) => _result.Match(
    value => value,
    _ => defaultValue);

public TResult UnwrapOrElse(Func<TResult> defaultValueFunction) => _result.Match(
    value => value,
    _ => defaultValueFunction());
        
public TResult UnwrapOrDefault() => _result.Match(
    value => value,
    _ => default);

In most of the above implementations, we are just forwarding the call to the OneOf library’s matching functions. These allow us to run functions depending on the internal state of the result. It would be wrong of us to not provide our own Match function after using the provided one so heavily:

1
2
3
4
5
public void Switch(Action<TResult> ok, Action<TError> error) =>_result.Switch(ok, error);
    
public TMatchResult Match<TMatchResult>(
    Func<TResult, TMatchResult> ok, Func<TError, TMatchResult> error)
    => _result.Match(ok, error);

Let’s see some usage of these functions to help illustrate their purpose:

1
2
3
4
5
6
7
8
9
10
11
var twentyFive = SquareString("5")
    .Expect("This should work!");

var notTwentyFive = SquareString("five")
    .ExpectError("This should not work!");

var defaultTwentyFive = SquareString("five")
    .UnwrapOr(25);

var defaultTwentyFiveFunction = SquareString("five")
    .UnwrapOrElse(() => 25);

Monadic Operations

The real power of a result type comes from its ability to be chained together with other result yielding operations. By composing many operations that return results, we can build up a chain of operations that can fail at any point. If an error is encountered, the chain will return the first error encountered.

The most basic result operations are transformations. These functions apply a function to a result if it is in the expected state, otherwise they return the result unchanged.

1
2
3
4
5
6
7
8
9
10
11
public Result<TNewResult, TError> Transform<TNewResult>(
    Func<TResult, Result<TNewResult, TError>> transform)
    => Match(
        ok: transform,
        error: Result<TNewResult, TError>.Error);

public Result<TResult, TNewError> TransformError<TNewError>(
    Func<TError, Result<TResult, TNewError>> transform)
    => Match(
        ok: Result<TResult, TNewError>.Ok,
        error: transform);

We can now start to chain together operations that might fail. For example, let’s say I want to try to parse an integer from a string, cube it and take the square root. This could fail because the string cannot be parsed, or we try to take the square root of a negative number.

1
2
Result<float, string> MyMagicOperation(string number)
    => CubeString(number).Transform(MySqrt);

We will also want to support boolean expressions in our result type. For example, one use case of this type might be to check all results from an array of operations are ‘Ok’. This is easily achievable by returning the next input result if we are in the ‘Ok’ state, otherwise returning the error result. If the final result is an ‘Ok’ then all of the conjuncts were ‘Ok’. If any of them were ‘Error’, then the final result will be the first ‘Error’ result encountered.

1
2
3
4
public Result<TNewResult, TError> And<TNewResult>(Result<TNewResult, TError> other)
    => Match(
        ok: _ => other,
        error: Result<TNewResult, TError>.Error);

We can invert this logic to get the Or function. If the value is ‘Ok’, we return the current result; else we return the input result. This allows a single ‘Ok’ result to propagate through the chain.

1
2
3
4
public Result<TResult, TNewError> Or<TNewError>(Result<TResult, TNewError> other)
    => Match(
        ok: Result<TResult, TNewError>.Ok,
        error: _ => other);

Now we can write more expressive code with boolean conditions for all of our dangerous operations!

1
2
3
4
if (myDangerousResult.And(MyOtherDangerousResult).And(MyFinalDangerousResult).IsOk)
{
    // Do something with the result
}

I toyed with the idea of adding an extra implicit conversion from Result<TResult, TError> to bool. Whilst it would look nice to drop the IsOk property at the end of that expression, it isn’t really idiomatic C# to have these types of conversions, so I left it out.

Improving Debugging Experience

.NET has a cool feature where you can specify an interpolated string for the debugger’s representation of a type. For example, if I have an Ok value, I’d quite like the debugger to show me Ok(/*value*/) so it’s easy to debug code that uses our result type. This is done through the DebuggerDisplayAttribute and other related types. Let’s add this to our Result<TResult, TError> type!

The constructor for DebuggerDisplayAttribute gets access to this and allows for some basic interpolation. If we add a helper function to render a string for the status along with the internal value of the result or error, we have achieved our desired debugger output.

1
2
3
4
5
6
7
8
9
10
11
[DebuggerDisplay("{DebugStateString}({_result})")]
public readonly struct Result<TResult, TError>
{
    // Only relevant implementation shown...

    private readonly OneOf<TResult, TError> _result;

    private string DebugStateString => IsOk ? "Ok" : "Error";
    
    public bool IsOk => _result.IsT0;
}

This is a useful tool to apply elsewhere in your C# code to improve debugging experience.

Conclusion

This ended up being a pretty code-y one but a nice break for me to write C# for a change. I don’t think this implementation is complete, but it was cool to think about the ethos of designing these types and learn a few new things about C# and functional programming.

There were a few interesting learning points for me:

  • Implementing a result type from scratch.
  • The OneOf library, which implements something important that C# is missing.
  • Using DebuggerDisplayAttribute to control the debugger’s presentation of a type.
  • Heterogenous literal initialization of types through implicit operators.

I hope you enjoyed this post and took something useful away from it. If nothing else, I hope it gave you pause for thought for error handling and functional design patterns.

This post is licensed under CC BY 4.0 by the author.