Introduction

In an earlier post, I created Result<TValue, TError>, a generic, allocation-free struct. On its own, it supports only procedural programming. Call something, check Success or Failure, branch and repeat. This is the same boilerplate as Go’s if err != nil.

In functional languages, a result is a value like any other, so operations can be composed on it directly. F# includes a Result module for this. This post discusses extension methods that reimplement map, mapError, bind, iter, iterError, defaultValue and defaultWith for Result<TValue, TError>. They let you chain steps 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:

  1. The type being extended can be Result<TValue, TError>, Task<Result<TValue, TError>> or ValueTask<Result<TValue, TError>> (three variants).
  2. The kind of delegate can be synchronous, return a Task or return a ValueTask (three variants).
  3. The number of extra arguments supplied to the delegate ranges from 0 to 3 (four variants).
  4. 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:

  1. The type being extended.
  2. The kind of delegate.
  3. The number of extra arguments.
  4. 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:

  1. The type being extended.
  2. The kind of delegate.
  3. The number of extra arguments.
  4. 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:

  1. The type being extended.
  2. The kind of delegate.
  3. The number of extra arguments.
  4. 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,
    TValue defaultValue)
    where TValue : notnull
    where TError : notnull
{
    return result.Success
        ? result.Value
        : defaultValue;
}

DefaultValue has one overload for each type being extended, three in all.

DefaultWith varies in:

  1. The type being extended.
  2. The kind of delegate.
  3. The number of extra arguments.
  4. 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:

  1. The type being extended.
  2. The kind of delegate, which applies to both handlers.
  3. 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 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

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

// 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:

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

[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 a ValueTask delegate.
  • Task<Result<TValue, TError>>: the overload with a Task delegate.
  • ValueTask<Result<TValue, TError>>: the overload with a ValueTask delegate.

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, so the compiler generates a class to hold the variable and allocates an instance of it on the heap. Passing the variable in as an argument leaves nothing to carry:

// captures factor, so a closure is allocated
var captured = result.Map(x => x * factor);

// factor is an argument, so no closure is generated
var passed = result.Map(static (x, factor) => x * factor, factor);

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 user registration 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<UserRegistered, string>> Handle(RegisterUserCommand command, CancellationToken ct)
{
    if (!IsEmailValid(command.Email))
    {
        return "Email is invalid";
    }

    var free = await EnsureEmailIsFree(command, ct);
    if (free.Failure)
    {
        return free.Error;
    }

    var user = CreateUser(free.Value);

    var saved = await SaveUser(user, ct);
    if (saved.Failure)
    {
        return saved.Error;
    }

    await SendWelcomeEmail(saved.Value, ct);

    return ToResponse(saved.Value);
}

To chain the email check, ValidateEmail wraps the bool from IsEmailValid in a result that holds the command when the email is valid and an error when it is not:

private static Result<RegisterUserCommand, string> ValidateEmail(RegisterUserCommand command) =>
    IsEmailValid(command.Email) ? command : "Email is invalid";

With the extensions, the branching is abstracted away and the same handler can be written as a single chain:

public ValueTask<Result<UserRegistered, string>> Handle(RegisterUserCommand command, CancellationToken ct) =>
    ValidateEmail(command)
        .Bind(EnsureEmailIsFree, ct)
        .Map(CreateUser)
        .Bind(SaveUser, ct)
        .Iter(SendWelcomeEmail, ct)
        .Map(ToResponse);

EnsureEmailIsFree and SaveUser can fail, so they are chained with Bind. CreateUser and ToResponse cannot fail, so they are chained with Map. SendWelcomeEmail performs a side effect, so it is chained with Iter, which passes the result along unchanged. The chain treats asynchronous and synchronous steps the same way. ct goes in as an extra argument 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.

The full source is on GitHub and the package is on NuGet.