Introduction
In C#, I handled every edge case that strayed from the happy path by throwing an exception. That worked at first, but each new edge case meant another exception type in the error handling middleware. Composing methods to reuse functionality got harder. Exceptions also aren’t cheap: throwing one measurably costs more than returning a value. Nor should they be relied on for control flow.
I also tried Go. An if err != nil after every call looks like boilerplate, but that boilerplate is 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 where exceptions are indirect. There’s no need to know about a try/catch further up the call stack. 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, such as a fixed enum or interface built to map onto an ASP.NET Core response. None of them was quite what I wanted.
Design and implementation
The result pattern implementation needs to meet the following requirements:
- Hold either a success value or an error, never both, never neither.
- Allow determining whether it contains a success value or an error.
- Be immutable.
- Be generic.
- 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 a cast reads it back:
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 ? (TValue)_value : default;
public TError? Error => Failure ? (TError)_value : default;
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<TValue, TError>
{
[FieldOffset(0)] private byte tag;
[FieldOffset(1)] private TValue 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. The actual layout is only evaluated at runtime, which is where the TypeLoadException surfaces.
The layout could only be tightened by making it allocate, and that’s not a trade I’m willing to make. A fixed amount of unused memory per result seems preferable to the allocation it would replace.
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:
public static class Result
{
public static Result<TValue, TError> Ok<TValue, TError>(TValue value)
where TValue : notnull
where TError : notnull
=> new(value);
public static Result<TValue, TError> Fail<TValue, TError>(TError error)
where TValue : notnull
where TError : notnull
=> new(error);
}
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.Ok<string, string>("test");
var fail = Result.Fail<string, string>("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.Ok<string, string>("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.Ok<string, string>("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:
// null assigned as success value
var result = Result.Ok<string?, int>(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.Ok<string?, int>(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.Fail<User, ValidationError>(new ValidationError("email"));
}
return Result.Ok<User, ValidationError>(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";
Conversions can’t be told apart by name, so the trick that helped the constructors doesn’t apply 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 succeed without a value to return. Others fail without an error to describe. In both cases Result<TValue, TError> still needs a type argument, and void isn’t a legal one:
// error CS1547: Keyword 'void' cannot be used in this context
Result<void, string> success;
// error CS1547: Keyword 'void' cannot be used in this context
Result<int, void> failure;
What’s needed is a type that stands in for “nothing”. I’d rather not rely on an interface or a base class for that, so I use a dedicated struct with nothing in it, the same idea as MediatR’s Unit:
public readonly struct Unit
{
public static Unit Value { get; } = default;
}
Unit has no fields and only one possible value, so there is nothing left to represent but “this happened”.
Conclusion
All five requirements hold. Result<TValue, TError> can contain only a value or an error, with Success and Failure saying 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 the runtime doesn’t allow on generic structs, so the layout stays suboptimal.
On its own, this type only supports a procedural style: call something, check Success or Failure, branch and repeat after every call. It’s the same shape as the if err != nil boilerplate from the introduction, just with a struct instead of two return values. A follow-up post covers the extension methods built on top of it, which bring a composed, chainable style to the same type.
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.