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 aNotFoundrecord 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.