Introduction

Whenever I was writing C# and an edge case popped up that strayed from the happy path, I just threw an exception. At first it felt fine and natural. But later it started bothering me, every new edge case meant adding another exception type to the error handling middleware. It was especially unpleasant when trying to compose different methods in order to reuse functionality. Exceptions aren’t cheap — throwing one measurably costs more than returning a value. Nor should they be relied on for control flow.

I also tried programming in Go. At first I didn’t like how it dealt with errors — an if err != nil after every call felt like unnecessary boilerplate. But that boilerplate turned out to be the point, a function’s signature reflects whether it can fail and the error gets handled right there instead of somewhere else. It’s explicit instead of indirect, unlike exceptions — there’s no need to be aware of a try/catch elsewhere in the call stack. Naturally, I wanted that same explicitness in C#.

Before writing my own, I looked at what already existed for .NET. Most wrap everything in a class, so every success and failure allocates. Others predefine the error shape — a fixed enum or interface, often built to map straight onto an ASP.NET Core response, whether or not that’s what I needed. Some keep the value-returning and no-value variants apart but hide both behind a shared interface, which boxes the struct. Some ship an extensive set of functional utilities, most of which I don’t find useful. None of it is wrong — just not what I wanted.

Design and implementation

The result pattern implementation needs to meet the following requirements:

  1. Hold either a success value or an error, never both, never neither.
  2. Allow determining whether it contains a success value or an error.
  3. Be immutable.
  4. Be generic.
  5. Avoid heap allocations.

With these requirements in mind, here’s a simple starting point:

public readonly struct Result<TValue, TError>
{
    public Result(TValue value)
    {
        Value = value;
        Error = default;
        Success = true;
    }

    public Result(TError error)
    {
        Value = default;
        Error = error;
        Success = false;
    }

    public TValue? Value { get; }
    public TError? Error { get; }

    public bool Success { get; }
    public bool Failure => !Success;
}

Struct layout

Every result carries a field it will never use. The success case has an empty error slot, the failure case an empty value slot. Either way, part of the layout is dead space. To see this concretely, take Result<Guid, string> as an example:

 0      16         24     25          32
 +------+----------+------+-----------+
 | Guid | *string  | flag |  padding  |
 +------+----------+------+-----------+

32 bytes, of which at least 8 are guaranteed to be unused on every instance. Guid takes 16 bytes, *string takes 8, and they are never present at the same time — so a single 16-byte slot would hold either:

 0                    16     17          24
 +--------------------+------+-----------+
 | max(Guid, *string) | flag |  padding  |
 +--------------------+------+-----------+

That’s 24 bytes, a quarter smaller, carrying exactly the same information.

There’s a way to get close to this. A single field typed object holds either case; the flag says which one is present, and Unsafe.As reads it back with no type check:

public readonly struct Result<TValue, TError>
{
    private readonly object _value;

    public Result(TValue value)
    {
        _value = value;
        Success = true;
    }

    public Result(TError error)
    {
        _value = error;
        Success = false;
    }

    public TValue? Value => Success ? Unsafe.As<TValue>(_value) : null;
    public TError? Error => Failure ? Unsafe.As<TError>(_value) : null;

    public bool Success { get; }
    public bool Failure => !Success;
}

This compiles and runs for any pair of type arguments. But anything that isn’t a reference type gets boxed on its way into the object field. A tighter layout that allocates on the heap isn’t a solution at all.

LayoutKind.Explicit is meant for exactly this kind of overlap:

[StructLayout(LayoutKind.Explicit)]
struct Result<TSuccess, TError>
{
    [FieldOffset(0)] private byte tag;
    [FieldOffset(1)] private TSuccess success;
    [FieldOffset(1)] private TError error;
}
// System.TypeLoadException: Could not load type 'Result`2' from assembly ''
// because generic types cannot have explicit layout.

It compiles, because Roslyn treats FieldOffset as metadata. Explicit struct layout is not possible for generic structs due to a runtime restriction. Actual layout is only evaluated at runtime — which is where the TypeLoadException surfaces.

The layout could only be tightened by making it allocate, which is a bad trade. A fixed amount of unused memory per result is cheaper than the allocation and collection that would replace it.

Constructor overload ambiguity

The most obvious usage is also the one that breaks first: set TValue and TError to the same type.

// error CS0121: The call is ambiguous between the following methods or properties
var result = new Result<string, string>("test");

The two constructors look different only because TValue and TError are still type parameters. Substitute the same type into both and they become indistinguishable. Constructors can’t be told apart by name. Fortunately, static methods don’t have that restriction:

private Result(TValue value) { ... }
private Result(TError error) { ... }

public static Result<TValue, TError> Ok(TValue value) => new(value);
public static Result<TValue, TError> Fail(TError error) => new(error);

The constructors are still ambiguous, but they’re no longer reachable from outside. Ok builds a successful result and Fail builds a failed one. The compiler picks the method by name instead of leaving it to overload resolution:

// CS0121 is missing.
var ok = Result<string, string>.Ok("test");
var fail = Result<string, string>.Fail("test");

Nullability inference

When a result is built, it’s clear whether it holds a value or an error. Checking the state flag later tells the compiler nothing:

var result = Result<string, string>.Ok("test");

if (result.Success)
{
    // CS8602: Possible dereference of null
    Console.WriteLine(result.Value.Length);
}
else
{
    // CS8602: Possible dereference of null
    Console.WriteLine(result.Error.Length);
}

Value returns TValue? and Error returns TError?, so both are nullable. As far as flow analysis is concerned, Success and Failure are unrelated flags that happen to sit on the same struct. The framework provides the MemberNotNullWhen attribute for exactly this. It ties the state flags to the nullability of properties:

[MemberNotNullWhen(true, nameof(Value))]
[MemberNotNullWhen(false, nameof(Error))]
public bool Success { get; }

[MemberNotNullWhen(false, nameof(Value))]
[MemberNotNullWhen(true, nameof(Error))]
public bool Failure => !Success;

With the flags decorated, nullability in both branches is inferred correctly:

var result = Result<string, string>.Ok("test");

if (result.Success)
{
    // CS8602 is missing
    Console.WriteLine(result.Value.Length);
}
else
{
    // CS8602 is missing
    Console.WriteLine(result.Error.Length);
}

Nullability override

Right now, nullable types can be used for either TValue or TError. Combined with the nullability inference just added, that opens the door to a bug:

var result = Result<string?, int>.Ok(null);

if (result.Success)
{
    // inferred as non-null string type
    var value = result.Value;
    // System.NullReferenceException: Object reference not set to an instance of an object.
    Console.WriteLine(value.Length);
}

Ok(null) is legal, because TValue is string?. When Success is true, MemberNotNullWhen then overrides the compiler’s own analysis, so the Value property’s return type is treated as not null. The warning that would have caught this never appears, and reading value.Length throws a NullReferenceException.

A nullable type argument quietly breaks the assumption the inference is built on. Constraining both type parameters keeps nullable type arguments out:

public readonly struct Result<TValue, TError>
    where TValue : notnull
    where TError : notnull
{
    ...
}

// CS8714: The type 'string?' cannot be used as type parameter 'TValue' in the generic type or method.
var result = Result<string?, int>.Ok(null);

Implicit conversion

Ok and Fail solved the ambiguity, but they left an ergonomic problem behind. A result is defined by two type parameters, and each creation method only ever sees one of them. Ok receives a TValue and has no way to know what TError should be. Fail has the same problem in reverse. C# has no partial type inference, so if one type argument can’t be inferred, both have to be written out:

public Result<User, ValidationError> Register(RegistrationRequest request)
{
    if (!IsValidEmail(request.Email))
    {
        return Result<User, ValidationError>.Fail(new ValidationError("email"));
    }

    return Result<User, ValidationError>.Ok(new User(request.Email));
}

Implicit conversions solve it from the other direction. Instead of inferring the result type from the argument, they let the compiler take it from the target:

public static implicit operator Result<TValue, TError>(TValue value) => new(value);
public static implicit operator Result<TValue, TError>(TError error) => new(error);

Anywhere the target type is already known (returns, assignments, arguments), the conversion applies and the generic definition can be inferred:

public Result<User, ValidationError> Register(RegistrationRequest request)
{
    if (!IsValidEmail(request.Email))
    {
        return new ValidationError("email");
    }

    return new User(request.Email);
}

The drawback is the one from earlier. Substitute the same type into both operators and they become indistinguishable, exactly as the constructors did:

// error CS0457: Ambiguous user defined conversions
Result<string, string> result = "test";

The trick that saved the constructors isn’t available here. When both type arguments are the same, Ok and Fail remain the only way to build a result, so the full syntax has to be written out.

Representing nothing

Some operations can succeed or fail without having anything meaningful to report. Result<TValue, TError> still needs a TValue, and void isn’t a legal one:

// error CS1547: Keyword 'void' cannot be used in this context
Result<void, string> result;

What’s needed is a common type to stand in for “nothing,” but the usual ways to build one are both closed off here. Inheritance is out: Result<TValue, TError> is a struct, and a struct can’t inherit from a base class, only implement interfaces. An interface would satisfy TValue, except the moment a struct value is passed around through an interface reference it gets boxed — the exact solution this whole design exists to avoid.

What’s left is a plain, standalone struct with nothing in it — a solution similar to MediatR’s own Unit, built for the same reason — something a notnull constraint will accept, whether standing in for TValue or TError:

public readonly struct Unit
{
    public static Unit Value { get; } = default;
}

No instance fields, nothing to initialize per value — just a single static property handing out the one instance that exists. Every Unit is identical to every other Unit, which is the entire point — there’s exactly one possible value, so there’s nothing left to represent but “this happened.”

Conclusion

All five requirements hold. Result<TValue, TError> can contain only a value or an error, Success and Failure say which one it has, the type itself is immutable and generic, none of that costs a heap allocation. The layout is the one thing I’d have liked tighter. TValue and TError still sit in two separate slots instead of one sized to the larger of the two. A real tagged union would need LayoutKind.Explicit, which isn’t allowed on generic structs, due to a runtime restriction, thus the layout stays suboptimal.

Thanks for reading this far. The full source is on GitHub, and the package is on NuGet. If it’s useful, or you just enjoyed reading how it came together, a star on the repo helps other people find it.