sean revised this gist 1 month ago. Go to revision
1 file changed, 107 insertions
result-with-error-hierarchy.md(file created)
| @@ -0,0 +1,107 @@ | |||
| 1 | + | # Result pattern with a closed error hierarchy (C#) | |
| 2 | + | ||
| 3 | + | A way to make a method's failure modes part of its return type without OneOf's | |
| 4 | + | positional `Tn` tedium or a flat opaque `Error` blob. Emulates a discriminated | |
| 5 | + | union using a sealed record hierarchy. | |
| 6 | + | ||
| 7 | + | ## What to call it | |
| 8 | + | ||
| 9 | + | There is no single name. It is three named things stapled together: | |
| 10 | + | ||
| 11 | + | - **Container:** the Result pattern (`Result<T, E>`). | |
| 12 | + | - **Error model:** a discriminated union (a.k.a. sum type, tagged union), | |
| 13 | + | *emulated* in C# with a closed/sealed record hierarchy because C# has no | |
| 14 | + | native DU. | |
| 15 | + | - **Consumption:** pattern matching via a switch expression. | |
| 16 | + | ||
| 17 | + | **Instruction that reliably produces it** (to a human or an agent): | |
| 18 | + | ||
| 19 | + | > Use the Result pattern with a closed (sealed record) error hierarchy as a | |
| 20 | + | > discriminated union, consumed via switch expression pattern matching. | |
| 21 | + | ||
| 22 | + | **Shortcut for people who know the languages:** "like Rust's `Result<T, E>` | |
| 23 | + | where `E` is an enum." F#/FP people will also recognise it as `Result` with a | |
| 24 | + | DU error, or as `Either` (Left = error, Right = success) if you want to start a | |
| 25 | + | monad conversation you cannot stop. | |
| 26 | + | ||
| 27 | + | > [!WARNING] | |
| 28 | + | > Say **closed** or **sealed** out loud. Drop the word and you get an open | |
| 29 | + | > inheritance tree with virtual methods and probably a visitor pattern, which is | |
| 30 | + | > the 2005 OOP answer to the same problem and strictly worse here. The point is | |
| 31 | + | > that the set is closed so the switch can be treated as exhaustive. | |
| 32 | + | ||
| 33 | + | ## The error hierarchy | |
| 34 | + | ||
| 35 | + | `abstract record` base, `sealed record` cases. This is the idiomatic C# way to | |
| 36 | + | fake a sum type. | |
| 37 | + | ||
| 38 | + | ```csharp | |
| 39 | + | public abstract record Error(string Code, string Message); | |
| 40 | + | ||
| 41 | + | public sealed record NotFound(string Code, string Message) : Error(Code, Message); | |
| 42 | + | public sealed record Validation(string Code, string Message, IReadOnlyList<string> Fields) : Error(Code, Message); | |
| 43 | + | public sealed record Conflict(string Code, string Message) : Error(Code, Message); | |
| 44 | + | public sealed record Forbidden(string Code, string Message) : Error(Code, Message); | |
| 45 | + | // ... thirty of these if you must | |
| 46 | + | ``` | |
| 47 | + | ||
| 48 | + | ## The signature stays boring | |
| 49 | + | ||
| 50 | + | ```csharp | |
| 51 | + | public Result<User, Error> GetUser(int id) | |
| 52 | + | ``` | |
| 53 | + | ||
| 54 | + | Generic over the *base*. Adding a thirty-first error subtype does not change this | |
| 55 | + | signature and does not touch any `Match`/`switch` that used a `_` fallthrough. | |
| 56 | + | ||
| 57 | + | ## Consuming it | |
| 58 | + | ||
| 59 | + | Handle what you care about, let the rest fall through. This is the | |
| 60 | + | "may or may not want to handle differently" case. | |
| 61 | + | ||
| 62 | + | ```csharp | |
| 63 | + | return result.Match( | |
| 64 | + | user => Ok(user), | |
| 65 | + | error => error switch | |
| 66 | + | { | |
| 67 | + | NotFound => NotFound(), | |
| 68 | + | Forbidden => Forbid(), | |
| 69 | + | Validation v => BadRequest(v.Fields), | |
| 70 | + | _ => Problem() // everything else bubbles to a 500 | |
| 71 | + | }); | |
| 72 | + | ``` | |
| 73 | + | ||
| 74 | + | ## Why this over the alternatives | |
| 75 | + | ||
| 76 | + | | Concern | OneOf | Flat `Result<T>` | This | | |
| 77 | + | |---|---|---|---| | |
| 78 | + | | Self-documenting signature | Yes (until ~30 arms) | No, opaque | Partly: base type is the catalogue | | |
| 79 | + | | Positional `Tn` tedium | Yes, error-prone | n/a | None | | |
| 80 | + | | Adding an error variant | Breaks every caller | Free | Free (with `_` fallthrough) | | |
| 81 | + | | Forced exhaustive handling | Yes | No | Opt-in via `_` discard | | |
| 82 | + | | Discoverability of error set | In the signature | Nowhere | "Find All Implementations" on `Error` | | |
| 83 | + | ||
| 84 | + | The tax: the signature alone no longer enumerates the error set, so finding the | |
| 85 | + | full catalogue means navigating to the base type rather than reading the return | |
| 86 | + | type. That is the price of not forcing every caller to acknowledge all thirty | |
| 87 | + | cases. This scales; OneOf does not. | |
| 88 | + | ||
| 89 | + | ## When NOT to reach for it | |
| 90 | + | ||
| 91 | + | - **Genuinely exceptional failures** (DB connection died, missing config, a | |
| 92 | + | broken invariant): throw. Do not `Result`-wrap a dropped socket. | |
| 93 | + | - **Expected, frequent outcomes on a warm path:** prefer this over exceptions. | |
| 94 | + | Exceptions are expensive (stack capture, low microseconds per throw), so | |
| 95 | + | routing "validation failed" through the panic channel when it fires on 15% of | |
| 96 | + | requests is a smell. | |
| 97 | + | - **Not-found that carries no substructure:** `User?` and a plain null is fine. | |
| 98 | + | Only reach for a `NotFound` record when you need more than the single bit | |
| 99 | + | "absent", e.g. to distinguish *not-found* from *exists-but-forbidden* (404 vs | |
| 100 | + | 403, or a deliberate 404 to avoid leaking existence). | |
| 101 | + | ||
| 102 | + | ## If you want exhaustiveness back | |
| 103 | + | ||
| 104 | + | There is no compiler-enforced exhaustive switch over a sealed hierarchy today; | |
| 105 | + | the `_` arm is doing the work. Native discriminated unions would fix this (a real | |
| 106 | + | checked switch, no positional `Tn`), but that feature has been "imminent" for | |
| 107 | + | years. Plan as though it is not here. | |
Newer
Older