Last active 1 month ago

A way to make a method's failure modes part of its return type without OneOf's positional Tn tedium or a flat opaque Error blob. Emulates a discriminated union using a sealed record hierarchy.

result-with-error-hierarchy.md Raw

Result pattern with a closed error hierarchy (C#)

A way to make a method's failure modes part of its return type without OneOf's positional Tn tedium or a flat opaque Error blob. Emulates a discriminated union using a sealed record hierarchy.

What to call it

There is no single name. It is three named things stapled together:

  • Container: the Result pattern (Result<T, E>).
  • Error model: a discriminated union (a.k.a. sum type, tagged union), emulated in C# with a closed/sealed record hierarchy because C# has no native DU.
  • Consumption: pattern matching via a switch expression.

Instruction that reliably produces it (to a human or an agent):

Use the Result pattern with a closed (sealed record) error hierarchy as a discriminated union, consumed via switch expression pattern matching.

Shortcut for people who know the languages: "like Rust's Result<T, E> where E is an enum." F#/FP people will also recognise it as Result with a DU error, or as Either (Left = error, Right = success) if you want to start a monad conversation you cannot stop.

[!WARNING] Say closed or sealed out loud. Drop the word and you get an open inheritance tree with virtual methods and probably a visitor pattern, which is the 2005 OOP answer to the same problem and strictly worse here. The point is that the set is closed so the switch can be treated as exhaustive.

The error hierarchy

abstract record base, sealed record cases. This is the idiomatic C# way to fake a sum type.

public abstract record Error(string Code, string Message);

public sealed record NotFound(string Code, string Message) : Error(Code, Message);
public sealed record Validation(string Code, string Message, IReadOnlyList<string> Fields) : Error(Code, Message);
public sealed record Conflict(string Code, string Message) : Error(Code, Message);
public sealed record Forbidden(string Code, string Message) : Error(Code, Message);
// ... thirty of these if you must

The signature stays boring

public Result<User, Error> GetUser(int id)

Generic over the base. Adding a thirty-first error subtype does not change this signature and does not touch any Match/switch that used a _ fallthrough.

Consuming it

Handle what you care about, let the rest fall through. This is the "may or may not want to handle differently" case.

return result.Match(
    user  => Ok(user),
    error => error switch
    {
        NotFound     => NotFound(),
        Forbidden    => Forbid(),
        Validation v => BadRequest(v.Fields),
        _            => Problem()   // everything else bubbles to a 500
    });

Why this over the alternatives

Concern OneOf Flat Result<T> This
Self-documenting signature Yes (until ~30 arms) No, opaque Partly: base type is the catalogue
Positional Tn tedium Yes, error-prone n/a None
Adding an error variant Breaks every caller Free Free (with _ fallthrough)
Forced exhaustive handling Yes No Opt-in via _ discard
Discoverability of error set In the signature Nowhere "Find All Implementations" on Error

The tax: the signature alone no longer enumerates the error set, so finding the full catalogue means navigating to the base type rather than reading the return type. That is the price of not forcing every caller to acknowledge all thirty cases. This scales; OneOf does not.

When NOT to reach for it

  • Genuinely exceptional failures (DB connection died, missing config, a broken invariant): throw. Do not Result-wrap a dropped socket.
  • Expected, frequent outcomes on a warm path: prefer this over exceptions. Exceptions are expensive (stack capture, low microseconds per throw), so routing "validation failed" through the panic channel when it fires on 15% of requests is a smell.
  • Not-found that carries no substructure: User? and a plain null is fine. Only reach for a NotFound record when you need more than the single bit "absent", e.g. to distinguish not-found from exists-but-forbidden (404 vs 403, or a deliberate 404 to avoid leaking existence).

If you want exhaustiveness back

There is no compiler-enforced exhaustive switch over a sealed hierarchy today; the _ arm is doing the work. Native discriminated unions would fix this (a real checked switch, no positional Tn), but that feature has been "imminent" for years. Plan as though it is not here.