In an earlier post, I created Result<TValue, TError>, a generic, allocation-free struct that lets code avoid using exceptions for control flow. On its own, it supports only a procedural style, where each call is followed by a check of Success or Failure. A few steps in, most of the code is branching.
Functional programming offers a way to hide the branches from the calling code. Instead of checking a result, the code composes operations on it. The operations do the checking. F# ships a Result module for exactly that. In this post I implement C# counterparts of its functions for Result<TValue, TError> as extension methods: map, mapError, bind, iter, iterError, defaultValue and defaultWith. I also add Match, an addition of my own. With these methods, steps can be chained without checking Success or Failure after each call.
Functional extensions
The extensions are straightforward. The hard part is ensuring the overloads cover enough use cases so that callers rarely need anything the library does not provide. To address this, I identified up to four aspects in which the overloads of each method differ:
- The type being extended can be
Result<TValue, TError>,Task<Result<TValue, TError>>orValueTask<Result<TValue, TError>>(three variants). - The kind of delegate can be synchronous, return a
Taskor return aValueTask(three variants). - The number of extra arguments supplied to the delegate ranges from 0 to 3 (four variants).
- Whether the delegate takes what the result holds (two variants).
Not every aspect applies to every extension. Knowing which aspects apply gives the number of overloads to expect, so a missing or an extra one is easier to spot.
Map and MapError
Map converts the success value into a new one and on failure returns the error unchanged. MapError is the opposite: it turns the error into a new one and on success returns the value unchanged:
public static Result<TNewValue, TError> Map<TValue, TError, TNewValue>(
this Result<TValue, TError> result,
Func<TValue, TNewValue> func)
where TValue : notnull
where TError : notnull
where TNewValue : notnull
{
return result.Success
? func(result.Value)
: result.Error;
}
public static Result<TValue, TNewError> MapError<TValue, TError, TNewError>(
this Result<TValue, TError> result,
Func<TError, TNewError> func)
where TValue : notnull
where TError : notnull
where TNewError : notnull
{
return result.Success
? result.Value
: func(result.Error);
}
Both vary in:
- The type being extended.
- The kind of delegate.
- The number of extra arguments.
- Whether the delegate takes what the result holds.
That results in 3 × 3 × 4 × 2 = 72 overloads each.
Bind
On success, Bind returns the new Result from its delegate; otherwise, the error:
public static Result<TNewValue, TError> Bind<TValue, TError, TNewValue>(
this Result<TValue, TError> result,
Func<TValue, Result<TNewValue, TError>> func)
where TValue : notnull
where TError : notnull
where TNewValue : notnull
{
return result.Success
? func(result.Value)
: result.Error;
}
Bind varies in:
- The type being extended.
- The kind of delegate.
- The number of extra arguments.
- Whether the delegate takes what the result holds.
That results in 3 × 3 × 4 × 2 = 72 overloads.
Iter and IterError
Iter performs a side effect by calling a delegate with the success value. IterError does the same with the error. Both return the result they were given:
public static Result<TValue, TError> Iter<TValue, TError>(
this Result<TValue, TError> result,
Action<TValue> action)
where TValue : notnull
where TError : notnull
{
if (result.Success)
{
action(result.Value);
}
return result;
}
public static Result<TValue, TError> IterError<TValue, TError>(
this Result<TValue, TError> result,
Action<TError> action)
where TValue : notnull
where TError : notnull
{
if (result.Failure)
{
action(result.Error);
}
return result;
}
Both vary in:
- The type being extended.
- The kind of delegate.
- The number of extra arguments.
- Whether the delegate takes what the result holds.
That results in 3 × 3 × 4 × 2 = 72 overloads each.
DefaultValue and DefaultWith
DefaultValue returns the success value or a fallback value when the result is a failure. DefaultWith calculates that fallback from the error with a delegate:
public static TValue DefaultValue<TValue, TError>(
this Result<TValue, TError> result,
TValue defaultValue)
where TValue : notnull
where TError : notnull
{
return result.Success
? result.Value
: defaultValue;
}
public static TValue DefaultWith<TValue, TError>(
this Result<TValue, TError> result,
Func<TError, TValue> func)
where TValue : notnull
where TError : notnull
{
return result.Success
? result.Value
: func(result.Error);
}
DefaultValue has one overload for each type being extended, three in all.
DefaultWith varies in:
- The type being extended.
- The kind of delegate.
- The number of extra arguments.
- Whether the delegate takes what the result holds.
That results in 3 × 3 × 4 × 2 = 72 overloads.
Match
To reduce a result to a single value, I added Match, which handles both cases:
public static TOutput Match<TValue, TError, TOutput>(
this Result<TValue, TError> result,
Func<TValue, TOutput> onSuccess,
Func<TError, TOutput> onFailure)
where TValue : notnull
where TError : notnull
{
return result.Success
? onSuccess(result.Value)
: onFailure(result.Error);
}
Match varies in:
- The type being extended.
- The kind of delegate, which applies to both handlers.
- The number of extra arguments.
That results in 3 × 3 × 4 = 36 overloads.
Async lambda overload ambiguity
With this many overloads, some become ambiguous, specifically those for Task and ValueTask delegates. An async lambda’s return type depends on the delegate it is passed to, so the same lambda can return Task or ValueTask. When overloads for both return types exist, the compiler cannot choose between them and reports the call as ambiguous:
public static Task<Result<TNewValue, TError>> Map<TValue, TError, TNewValue>(
this Result<TValue, TError> result,
Func<TValue, Task<TNewValue>> func)
where TValue : notnull
where TError : notnull
where TNewValue : notnull
public static ValueTask<Result<TNewValue, TError>> Map<TValue, TError, TNewValue>(
this Result<TValue, TError> result,
Func<TValue, ValueTask<TNewValue>> func)
where TValue : notnull
where TError : notnull
where TNewValue : notnull
// error CS0121: The call is ambiguous between the following methods or properties
var dto = await user.Map(async u => new UserDto(u.Id, await LoadAvatarUrl(u.Id)));
C# 13 added [OverloadResolutionPriority], which assigns a score to methods. When several overloads apply, the compiler prefers the one with the highest score. Giving one of two ambiguous overloads a higher score resolves the ambiguity:
// has score of 0
public static async Task<Result<TNewValue, TError>> Map<TValue, TError, TNewValue>(
this Result<TValue, TError> result,
Func<TValue, Task<TNewValue>> func)
where TValue : notnull
where TError : notnull
where TNewValue : notnull
// has score of 1
[OverloadResolutionPriority(1)]
public static async ValueTask<Result<TNewValue, TError>> Map<TValue, TError, TNewValue>(
this Result<TValue, TError> result,
Func<TValue, ValueTask<TNewValue>> func)
where TValue : notnull
where TError : notnull
where TNewValue : notnull
// CS0121 is missing.
var dto = await user.Map(async u => new UserDto(u.Id, await LoadAvatarUrl(u.Id)));
In this library, the overload that gets the higher score depends on the type being extended:
Result<TValue, TError>: the overload with aValueTaskdelegate.Task<Result<TValue, TError>>: the overload with aTaskdelegate.ValueTask<Result<TValue, TError>>: the overload with aValueTaskdelegate.
Avoiding closures with extra arguments
A library built around avoiding heap allocations has to be mindful of closures. A lambda that reads a variable from the enclosing scope has to carry it along:
// captures factor, so a closure is allocated
var captured = result.Map(x => x * factor);
To make that possible, the compiler rewrites the code into something similar to this:
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int factor;
internal int <Run>b__0(int x)
{
return x * factor;
}
}
<>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
<>c__DisplayClass0_.factor = factor;
var captured = result.Map(new Func<int, int>(<>c__DisplayClass0_.<Run>b__0));
The generated class exists only to hold factor for the lambda body. Each time the code runs, an instance of it is allocated on the heap, together with a delegate that points at its method.
Passing the variable in as an argument leaves nothing to carry:
// no closure is generated now
var passed = result.Map(static (x, y) => x * y, factor);
The lambda reads nothing from the enclosing scope, so the compiler emits it as a plain method and caches the delegate. Nothing is allocated after the first call.
A library cannot stop callers from writing a capturing lambda. What it can do is give them a way to opt out, which is why every extension that takes a delegate accepts up to three extra arguments and passes them to the delegate.
Putting it together
With the extensions and their overloads in place, here is what changes in practice. Take a hypothetical funds transfer command handler. Some steps can fail. Others are asynchronous, meaning they are awaited, while the rest are synchronous. Written procedurally, every step that can fail needs a check before the next one runs:
public async ValueTask<Result<Transferred, TransferError>> Handle(TransferCommand command, CancellationToken ct)
{
if (command.From == command.To)
{
return TransferError.SameAccount;
}
var from = await Load(command.From, ct);
var to = await Load(command.To, ct);
if (from is null || to is null)
{
return TransferError.NotFound;
}
if (from.Balance < command.Amount)
{
return TransferError.InsufficientFunds;
}
var plan = new TransferPlan(from, to, command.Amount);
await Persist(plan, ct);
return new Transferred(command.From, command.To, command.Amount);
}
To chain the first check, EnsureDifferentAccounts turns the comparison into a result that holds the command when the accounts differ and an error when they do not:
private static Result<TransferCommand, TransferError> EnsureDifferentAccounts(TransferCommand command) =>
command.From == command.To ? TransferError.SameAccount : command;
With the extensions, the branching is abstracted away and the same handler can be written as a single chain:
public ValueTask<Result<Transferred, TransferError>> Handle(TransferCommand command, CancellationToken ct) =>
EnsureDifferentAccounts(command)
.Bind(LoadAccounts, ct)
.Bind(EnsureFunds)
.Bind(Save, ct)
.Map(ToResponse, command);
LoadAccounts, EnsureFunds and Save can fail, so they are chained with Bind. ToResponse cannot fail, so it is chained with Map. The chain treats asynchronous and synchronous steps the same way. ct and command go in as extra arguments instead of being captured.
Conclusion
The functional style is now available for Result<TValue, TError> as extension methods that reimplement F#’s Result functions. With the overloads worked out, chaining is easy, delegates can be passed as they are and async lambdas no longer cause ambiguity. Extra arguments let callers avoid closures when it matters. All of the extensions live in the same namespace, so callers never have to work out which namespace holds which overload just to chain a few calls. The result is a happy path that reads top to bottom without a check after every call. Further extensions are possible, but most of them can be built by combining the current ones.