# 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`). - **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` 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. ```csharp 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 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 ```csharp public Result 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. ```csharp 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` | 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.