Introduction

I’ve spent the last few years moving away from throwing exceptions for every business‑rule violation. Exceptions are expensive, they break the flow of async code, and they make unit tests noisy. A lightweight Result<T> type gives you a single, composable way to represent success or failure without leaving the happy path.

The Problem with Exceptions

When a service calls an external API, a network glitch or a 4xx response is not an exceptional circumstance — it’s a predictable outcome. Wrapping those cases in try/catch forces callers to handle Exception types they don’t own, and it obscures the real domain errors. Moreover, stack‑trace capture adds measurable overhead in high‑throughput paths.

The Result<T> Type

The core idea is a discriminated union that either holds a value or an error object. In C# 12 we can express it cleanly with a readonly record struct:

public readonly record struct Result<T>
{
    private readonly T _value;
    private readonly Error _error;
    private readonly bool _isSuccess;

    private Result(T value)
    {
        _value = value;
        _error = default;
        _isSuccess = true;
    }

    private Result(Error error)
    {
        _value = default;
        _error = error;
        _isSuccess = false;
    }

    public bool IsSuccess => _isSuccess;
    public T Value => _isSuccess ? _value : throw new InvalidOperationException("No value on failure");
    public Error Error => !_isSuccess ? _error : throw new InvalidOperationException("No error on success");

    public static implicit operator Result<T>(T value) => new Result<T>(value);
    public static implicit operator Result<T>(Error error) => new Result<T>(error);
}

public readonly record struct Error(string Code, string Message);

Implicit Conversions

The two implicit operators let you return a value or an Error directly from a method without ceremony. Callers can pattern‑match on IsSuccess or use the new switch expression syntax:

public Result<UserProfile> GetProfile(string userId)
{
    if (!Guid.TryParse(userId, out var guid))
        return new Error("INVALID_ID", "User id must be a GUID");

    var profile = _repository.Find(guid);
    return profile is null
        ? new Error("NOT_FOUND", $"User {guid} does not exist")
        : profile;
}

Extension Methods for Chaining

To keep the code fluent I add a few extensions that mirror Option‑style combinators:

public static class ResultExtensions
{
    public static Result<TOut> Map<TIn, TOut>(this Result<TIn> result, Func<TIn, TOut> mapper)
        => result.IsSuccess ? mapper(result.Value) : result.Error;

    public static Result<TOut> Bind<TIn, TOut>(this Result<TIn> result, Func<TIn, Result<TOut>> binder)
        => result.IsSuccess ? binder(result.Value) : result.Error;

    public static async Task<Result<TOut>> BindAsync<TIn, TOut>(this Task<Result<TIn>> task, Func<TIn, Task<Result<TOut>>> binder)
    {
        var result = await task;
        return result.IsSuccess ? await binder(result.Value) : result.Error;
    }
}

Real‑World Example: Calling an External API

Imagine a checkout service that needs to reserve inventory, charge a payment gateway, and then confirm the order. Each step can fail for business reasons (out of stock, declined card) or technical reasons (timeout). Using Result<T> the flow stays linear:

public async Task<Result<OrderConfirmation>> PlaceOrder(OrderRequest request)
{
    return await Validate(request)
        .BindAsync(ReserveInventory)
        .BindAsync(ChargePayment)
        .BindAsync(ConfirmOrder);
}

private Result<OrderRequest> Validate(OrderRequest req)
    => req.Items.Any() ? req : new Error("EMPTY_CART", "Cannot place an empty order");

private async Task<Result<Reservation>> ReserveInventory(OrderRequest req)
{
    var result = await _inventory.Reserve(req.Items);
    return result.IsSuccess ? result.Value : new Error("OUT_OF_STOCK", result.Error.Message);
}

private async Task<Result<PaymentReceipt>> ChargePayment(Reservation reservation)
{
    var charge = await _payments.Charge(reservation.Total, reservation.Currency);
    return charge.IsSuccess ? charge.Value : new Error("PAYMENT_DECLINED", charge.Error.Message);
}

private async Task<Result<OrderConfirmation>> ConfirmOrder(PaymentReceipt receipt)
{
    var order = await _orders.Create(receipt);
    return order.IsSuccess ? order.Value : new Error("ORDER_FAILED", order.Error.Message);
}

Testing Benefits

  • Unit tests become pure value assertions — no need to catch exceptions.
  • Error paths are exercised by simply returning an Error from a stub.
  • Because Result<T> is a value type, there’s no allocation pressure in hot paths.
Tip: Pair this pattern with FluentResults or OneOf if you need richer error hierarchies, but the minimal struct above covers 90 % of scenarios with zero dependencies.

Closing Thoughts

Adopting a Result<T> type doesn’t mean you never throw — reserve exceptions for truly unrecoverable faults like configuration errors or invariant violations. For everything else, a explicit success/failure value makes the contract visible at compile time, improves readability, and keeps the call stack clean. Give it a try on the next service boundary you touch; you’ll notice the difference in both debugging sessions and code reviews.