~/handys11
Browse the docs

dotnet/Design Patterns

The Result pattern

intermediate

TL;DR

public readonly record struct Result<T>
{
    private Result(T? value, string? error) => (Value, Error) = (value, error);

    public T? Value { get; private init; }
    public string? Error { get; private init; }

    [MemberNotNullWhen(true, nameof(Value))]
    public bool IsSuccess => Error is null;

    public static Result<T> Ok(T value) => new(value, null);
    public static Result<T> Fail(string error) => new(default, error);

    public TOut Match<TOut>(Func<T, TOut> ok, Func<string, TOut> fail) =>
        IsSuccess ? ok(Value) : fail(Error!);
}

// Failures are data, not control flow:
Result<User> result = await GetUserAsync(id);
return result.Match<IResult>(
    ok: user => Results.Ok(user),
    fail: error => Results.NotFound(error));

How it works

Exceptions in .NET are for the exceptional. Throwing and unwinding the stack to a handler costs orders of magnitude more than an ordinary return — and a throw is invisible in the method signature. Nothing about Task<User> GetUserAsync(Guid id) tells a caller “this can fail”; most callers won’t guard against it until production proves them wrong.

Result<T> puts the failure back on the signature. GetUserAsync returning Result<User> instead of User turns “no such user” into data the caller receives and must act on, not a control-flow event they can forget to catch. Match is the enforcement mechanism: it takes both an ok and a fail delegate, so there’s no code path that reads Value without also having decided what happens on failure — compare that to a nullable return, where skipping the null check compiles fine and blows up on the first unlucky input.

The [MemberNotNullWhen(true, nameof(Value))] attribute on IsSuccess is what lets Value inside Match’s true branch analyze as non-null without an explicit check or a !: the compiler trusts the annotation and narrows Value’s nullable state anywhere IsSuccess has already been tested, if or ternary alike. That’s only sound because the constructor is privateOk and Fail are the only paths to a “real” instance. Result<T> is still a struct, though, so default(Result<T>) exists and bypasses the constructor entirely: Error is null there too, so IsSuccess reads true on a value nobody built through Ok. Harmless as long as every instance comes from the factories — worth remembering if a Result<T> ever ends up as an uninitialized field or array element.

Chaining without a pyramid of ifs

A single Match at the API boundary is the common case, but a pipeline that runs a success value through several fallible steps benefits from Map/Then-style extension methods that skip the transform on failure and let the failure just flow through. That’s what “railway-oriented programming” means: two parallel tracks, success and failure, that only converge at the end.

public static Result<TOut> Map<TIn, TOut>(
    this Result<TIn> result, Func<TIn, TOut> map) =>
    result.IsSuccess ? Result<TOut>.Ok(map(result.Value)) : Result<TOut>.Fail(result.Error!);

Chained — (await GetUserAsync(id)).Map(u => u.Email).Map(NormalizeEmail) — that reads as a straight line of happy-path transforms, with the failure handling factored out entirely instead of an if (user is null) return ... after every step.

None of this replaces exceptions everywhere. Bugs and broken invariants — a null argument that should never be null, a switch that hit an “impossible” default, a violated class invariant — should still throw. Those aren’t outcomes for a caller to branch on; they’re defects that should crash loudly and get fixed, not get quietly funneled into an error string.

Gotchas

  • Don’t wrap everything in Result. Reach for it on domain outcomes — “user not found,” “validation failed,” “insufficient stock” — where callers need to react to failure as normal control flow. Infrastructure faults (a dropped connection, a corrupted file, a cancelled operation) are still exceptions; catching around every I/O call to funnel it into a Result just reinvents try/catch badly.
  • A string error channel is a starting point, not the destination. Real applications outgrow string Error quickly — a typed error (an enum, or an error record) lets callers switch on the kind of failure instead of pattern-matching on message text.
  • There’s no compiler exhaustiveness for error kinds — yet. Until C# gets discriminated unions, the compiler can’t force every arm of a switch over your error type to be handled the way it does for a sealed hierarchy. Libraries like ErrorOr and OneOf encode “one of N specific outcomes” with generics today, closer to what a union would give you.
  • Mixing conventions is worse than picking either one. A codebase where half the services throw and half return Result forces every caller to know, ambiguously, which style each dependency uses. Pick a boundary — layer, module, or team convention — and stay consistent on each side of it.
  • You lose the stack trace at the point of failure. Result.Fail("not found") doesn’t remember where GetUserAsync was called from the way a thrown exception’s stack trace would. Attach whatever context matters (an ID, the failing step, a correlation ID) to the error itself — you don’t get it for free anymore.

Further reading