Result Monad

Result<T> represents the outcome of a computation that may fail. It is either Ok with a value of type T, or Error with an Exception. Where Option<T> only tells you that something is missing, Result<T> also tells you why.

The type is deliberately tied to Exception. Funcky does not introduce its own error hierarchy; the error case carries whatever your code or the BCL already throws. This makes Result<T> the natural return type for a function that would otherwise throw, and it means an error can be turned back into a thrown exception at the boundary without losing information. When the error type should be something else, use Either<L, R>.

Think of it as Option<T> with a reason

Every method you know from Option<T> exists on Result<T> with the same name and the same meaning. The only difference is that the empty case is called error instead of none, and that it carries a value:

Option<T>Result<T>
Some(value)Ok(value)
NoneError(exception)
Match(none, some)Match(ok, error)
Switch(none, some)Switch(ok, error)
InspectNoneInspectError
ToNullableGetOrThrow

Select, SelectMany, OrElse, GetOrElse and Inspect are identical, and query syntax works the same way. The one operation missing is Where: filtering out a value would need an exception to put in the Error case, and there is no sensible default for it. Apart from that, the ladder in the Option chapter, from "stay inside with Select" to "handle both cases with Match", applies without change.

Creating results

// Ok
Result<int> ok = Result.Ok(42);
Result<int> alsoOk = 42;                    // implicit conversion from T
Result<int> viaReturn = Result.Return(42);  // same as Ok, the name monads use

// Error
Result<int> error = Result<int>.Error(new InvalidOperationException("boom"));

The type parameter is constrained to notnull, and Ok(null) throws. Unlike Option<T>, a Result<T> has no meaningful default: the struct is marked [NonDefaultable], and λ1009 from Funcky.Analyzers reports an error if you try to construct one with default.

Result<T>.Error has one side effect worth knowing about. If the exception has not been thrown yet, it has no stack trace, and the error would be useless once it surfaces somewhere else. Error therefore captures the current stack trace on the exception when none is set. The Error(…) call site is where the stack trace points, which is almost always what you want.

Chaining operations that may fail

The typical use of Result<T> is a pipeline of steps where each step can go wrong and the first failure should stop the rest:

Result<Order> PlaceOrder(string customerId, string productId, int quantity)
    => from customer in FindCustomer(customerId)
       from product in FindProduct(productId)
       from stock in ReserveStock(product, quantity)
       select new Order(customer, product, quantity);

If FindCustomer returns an Error, neither FindProduct nor ReserveStock run, and the Error is passed through unchanged with its exception intact. There is no try and no catch, and the signature of PlaceOrder tells the caller that it can fail. Compare that with the exception-based version, where the only way to know which exceptions a method throws is to read its body.

Recovering from an error

OrElse and GetOrElse have two overloads each. The one that takes a plain fallback discards the exception, the one that takes a function receives it, so you can decide based on what went wrong:

// Discards the exception: every failure is treated the same.
Result<Config> config = ReadConfig(path).OrElse(Result.Ok(Config.Default));

// Receives the exception: recover from some failures, keep the rest.
Result<Config> config = ReadConfig(path).OrElse(exception => exception switch
{
    FileNotFoundException => Result.Ok(Config.Default),
    _ => Result<Config>.Error(exception),
});

The same applies to GetOrElse, which leaves the result and gives you a T:

int retries = ReadSetting("retries").GetOrElse(3);
int retries = ReadSetting("retries").GetOrElse(exception => LogAndDefault(exception, 3));

Leaving the result at the boundary

Match and Switch

As with options, Match is the general tool and should be the last one you reach for:

IActionResult response = PlaceOrder(customerId, productId, quantity).Match(
    ok: order => Created(order),
    error: exception => BadRequest(exception.Message));

Switch is the same for actions. Always name the arguments; λ1003 enforces it, and λ1005 to λ1007 flag Match calls that should have been GetOrElse or OrElse.

GetOrThrow

Result<T> is often the right type inside your code and the wrong type at the edge, for example in a Main method, a test, or a framework callback that expects exceptions. GetOrThrow returns the value in the Ok case and rethrows the exception in the Error case:

Order order = PlaceOrder(customerId, productId, quantity).GetOrThrow();

The exception is rethrown with its original stack trace preserved, not wrapped and not reset. Together with the capture in Error, this means a failure deep in a pipeline reports the location where it was created, even though the throw happens somewhere else entirely.

Inspect and InspectError

Both run an action and return the result unchanged, which is convenient for logging in the middle of a chain:

Result<Order> order = PlaceOrder(customerId, productId, quantity)
    .Inspect(order => logger.LogInformation("placed order {Id}", order.Id))
    .InspectError(exception => logger.LogError(exception, "order failed"));

Working with many results

Sequence and Traverse

A sequence of results is rarely what you want to hand on; usually you want one result that is Ok with all the values, or the first Error. Sequence does exactly that for an IEnumerable<Result<T>>, and Traverse combines it with a Select:

Result<IReadOnlyList<Order>> orders = orderRequests
    .Traverse(request => PlaceOrder(request.CustomerId, request.ProductId, request.Quantity));

If every call succeeds, the result is Ok with a list of orders. If any call fails, the result is that Error, and the remaining requests are not processed.

Partition

When you want to process everything and collect both the failures and the successes, Partition splits an IEnumerable<Result<T>> into two lists:

var (errors, orders) = orderRequests
    .Select(request => PlaceOrder(request.CustomerId, request.ProductId, request.Quantity))
    .Partition();

logger.LogWarning("{Count} orders failed", errors.Count);

Partition materializes the whole sequence. It is also available with a result selector that receives both lists as separate parameters.

Combining with other monads

Traverse and Sequence also exist for swapping a Result<T> with another monad that sits inside it: Result<Option<T>> becomes Option<Result<T>>, Result<Either<L, R>> becomes Either<L, Result<R>>, and so on for Lazy<T>, Reader<E, T> and IEnumerable<T>. The same operations exist in the other direction on Option<T>, so Option<Result<T>>.Sequence() gives you a Result<Option<T>>.

Where to go next

  • Option Monad explains the common API in more depth; everything there applies here too.
  • Either Monad is the choice when the error should be something other than an Exception.