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.

sean's Avatar 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