Result Types versus Exceptions
Objective
C# has one built-in way to report failure: throw. The BCL adds a second for
cheap, expected failures (TryParse, TryGetValue). Many application
codebases add a third: a method that returns either a value or an error
object, usually called Result<T>. This concept compares the three on the
axes that decide the choice (which failures are expected, what the caller is
forced to see, cost, composition, diagnostics), builds a small Result<T>
with the traps a hand-written one has, maps it to HTTP ProblemDetails, and
shows what two popular libraries (OneOf and ErrorOr) look like. The mechanics
of throw and catch are in exceptions-try-catch-filters-and-finally, guard
clauses in guard-clauses-throwing-and-rethrowing, and the Try pattern in
parsing-tryparse-and-the-try-pattern.
Use Cases
- Deciding how an application service reports "not found", "invalid input" and "conflict" to its caller.
- Mapping domain failures to HTTP status codes without a global exception handler full of special cases.
- Chaining several steps that can each fail (validate, load, check, save)
without nested
ifblocks. - Reviewing a pull request that adds a
Result<T>type or a library for it.
Deep Dive
Output is from .NET 10 (SDK 10.0.100) on arm64. Libraries: OneOf 3.0.271 and ErrorOr 2.1.1 from NuGet.
Three kinds of failure
The decision starts with the kind of failure, not with a style preference:
| Kind | Example | Usual mechanism |
|---|---|---|
| Usage error (a bug) | null argument, negative quantity from code | exception (ArgumentException family) |
| Expected outcome | product not found, stock too low, invalid form field | Try method, nullable return, or a result type |
| Environment failure | database down, disk full, timeout | exception |
Usage errors should fail loudly, with a stack trace, and be fixed. Environment failures usually cannot be handled by the immediate caller, and the exception carries them up to a place that can (a retry policy, a global handler). The middle row is where the choice is real: the failure is part of the business, the caller must decide what to do, and it happens often.
The Framework Design Guidelines (a 2008 reprint, marked as possibly out of
date) say "DO NOT return error codes" and "DO report execution failures by
throwing exceptions", and in the same page "DO NOT use exceptions for the
normal flow of control, if possible", recommending the tester-doer and Try
patterns. Those rules were written for reusable framework APIs. Inside an
application, the current best practices page also suggests returning null or
default for common error cases. A result type is the richer version of that
advice: a return value that also says why.
A minimal Result<T>
csharpenum ErrorKind { Validation, NotFound, Conflict }
sealed record Error(ErrorKind Kind, string Code, string Message)
{
public static Error Validation(string code, string msg) => new(ErrorKind.Validation, code, msg);
public static Error NotFound(string code, string msg) => new(ErrorKind.NotFound, code, msg);
public static Error Conflict(string code, string msg) => new(ErrorKind.Conflict, code, msg);
}
readonly struct Result<T>
{
static readonly Error Unset = new(ErrorKind.Validation, "result.default", "default(Result<T>) has no value");
readonly T _value;
readonly Error? _error;
readonly bool _ok; // false in default(Result<T>)
Result(T value) { _value = value; _ok = true; }
Result(Error error) { _value = default!; _error = error; }
public bool IsSuccess => _ok;
public bool IsFailure => !_ok;
public Error? Error => _ok ? null : _error ?? Unset;
public T Value => _ok ? _value : throw new InvalidOperationException($"no value: {Error!.Code}");
public static Result<T> Ok(T value) => new(value);
public static Result<T> Fail(Error error) => new(error);
public static implicit operator Result<T>(T value) => new(value);
public static implicit operator Result<T>(Error error) => new(error);
public Result<U> Bind<U>(Func<T, Result<U>> next) => _ok ? next(_value) : Error!;
public Result<U> Map<U>(Func<T, U> f) => _ok ? f(_value) : Error!;
public R Match<R>(Func<T, R> ok, Func<Error, R> fail) => _ok ? ok(_value) : fail(Error!);
}Three decisions in this code are easy to get wrong:
defaultmust not be a success. The first version of this struct computedIsSuccess => Error is null. ThenResult<int> d = default;reportedIsSuccess=TruewithValue0, a success nobody produced. With an explicit_okflag,defaultis a failure:IsSuccess=False,Error=result.default, andd.ValuethrowsInvalidOperationException: no value: result.default. (Aclassavoidsdefaultbut allocates on every success.)Valueon a failure throws. Reading the value without checking is a bug, so it is an exception, exactly likeNullable<T>.Value.- Implicit conversions keep call sites short.
return qty;andreturn Error.NotFound(...);both compile to aResult<int>, and a conditional?:with one branch of each kind is target-typed to it.
Composing steps
Bind runs the next step only when the previous one succeeded, so a chain
stops at the first failure without any if:
csharpResult<int> ValidateQty(int qty) => qty > 0 ? qty : Error.Validation("qty.invalid", "quantity must be positive");
Result<int> FindStock(string sku) => catalog.TryGetValue(sku, out var s) ? s : Error.NotFound("product.missing", $"no product {sku}");
Result<int> PlaceOrder(string sku, int qty) =>
ValidateQty(qty)
.Bind(q => FindStock(sku)
.Bind(stock => stock >= q ? Result<int>.Ok(stock - q)
: Error.Conflict("stock.short", $"only {stock} {sku} left")));
// catalog: apple = 5, pear = 0
PlaceOrder("apple", 2) // ok, 3 left
PlaceOrder("apple", 0) // Validation qty.invalid: quantity must be positive
PlaceOrder("kiwi", 1) // NotFound product.missing: no product kiwi
PlaceOrder("pear", 1) // Conflict stock.short: only 0 pear leftThe exception version is shorter to write: three throw statements and no
wrapper type. What it loses is the signature. int PlaceOrderEx(string, int)
does not say that it can fail in three business ways; the caller learns it
from documentation or from production. With Result<int>, the compiler forces
the caller to unwrap. It does not force the caller to look: a discarded
Result compiles without a warning, while a discarded exception still stops
the flow.
Async code adds a layer: a step returns Task<Result<T>>, and each await
must be followed by the success check, so helpers multiply (BindAsync,
MapAsync). That is the moment teams usually reach for a library.
What it costs
Release build, 100,000 calls that all fail with "not found":
textexceptions (throw + catch KeyNotFoundException) 221 ms 248 bytes per failure Result<int> 6 ms 288 bytes per failure
The exception path cost about 2.2 microseconds per failure, between 35 and 100
times slower than the Result path across runs (the Result loop measured
2 to 6 ms). The gap is in time, not memory: the Result path allocated a
little more (the Error object, the interpolated message and the lambdas'
closures). The guideline page puts the threshold where it starts to matter:
"Throw rates above 100 per second are likely to noticeably impact the
performance of most applications." A login endpoint that rejects bad passwords
or an import that skips invalid rows can reach that easily.
Results at the HTTP boundary
A result type pays off when the API layer translates it in one place. The
error kind becomes the status code, and the body is a ProblemDetails:
csharpstatic IResult ToHttp<T>(Result<T> result) => result.Match(
value => Results.Ok(value),
error => Results.Problem(
statusCode: error.Kind switch
{
ErrorKind.Validation => StatusCodes.Status400BadRequest,
ErrorKind.NotFound => StatusCodes.Status404NotFound,
ErrorKind.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError,
},
title: error.Code,
detail: error.Message));
app.MapPost("/orders/{sku}/{qty:int}", (string sku, int qty) => ToHttp(Reserve(sku, qty)));textPOST /orders/apple/2 -> 200 3 POST /orders/kiwi/1 -> 404 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"product.missing","status":404,"detail":"no product kiwi"} POST /orders/apple/9 -> 409 {"type":"...#section-15.5.10","title":"stock.short","status":409,"detail":"only 5 left"} POST /orders/apple/0 -> 400 {"type":"...#section-15.5.1","title":"qty.invalid","status":400,"detail":"quantity must be positive"}
The alternative is to throw domain exceptions and convert them in an
IExceptionHandler (see problem-details-and-global-exception-handling).
Both work; the global handler keeps use cases free of a wrapper type, and the
result keeps expected failures out of the exception path and visible in the
use case signature (clean-architecture-in-four-projects uses a per-use-case
result with a status enum, a lighter variant of the same idea).
The switch above needs the _ arm. C# has no closed union type yet: even an
abstract record with a private constructor and two sealed nested records gives
CS8509 ("The switch expression does not handle all possible values of its
input type") when both subtypes are matched, because the compiler does not
treat the hierarchy as closed.
OneOf and ErrorOr
Two NuGet packages cover most of what teams hand-write.
OneOf models "one of these types". The failure is a type, and Match takes
one lambda per case:
csharpOneOf<int, NotFound> Find(string sku) => stock.TryGetValue(sku, out var s) ? s : new NotFound();
Find("apple").Match(s => $"{s} in stock", _ => "not found") // 5 in stock
Find("kiwi").Match(s => $"{s} in stock", _ => "not found") // not foundErrorOr models "a value or a list of errors", with predefined error types and
Then for chaining:
csharpErrorOr<int> Find(string sku) => stock.TryGetValue(sku, out var s) ? s : Error.NotFound("product.missing", $"no product {sku}");
ErrorOr<int> Reserve(string sku, int qty) =>
Find(sku).Then(s => s >= qty ? (ErrorOr<int>)(s - qty) : Error.Conflict("stock.short", $"only {s} left"));
Reserve("apple", 2) // 3 left, IsError=False
Reserve("apple", 9) // Conflict stock.short: only 5 left, IsError=True
Reserve("kiwi", 1) // NotFound product.missing: no product kiwi, IsError=TrueTrade-offs
- Visibility versus ceremony. A
Result<T>return type documents expected failures in the signature and makes the caller unwrap. Every layer then carries the wrapper, including code paths that cannot fail in practice. - Exceptions keep the stack trace; results keep the flow. An
Errorrecord has no stack trace and no inner exception, so a result is the wrong carrier for an unexpected failure that someone must debug. Never catchExceptionjust to turn it into aResult. - Speed matters only at high failure rates. 2 microseconds per throw is irrelevant for a rare "not found" and real for a validation endpoint under load. Measure the failure rate before rewriting for performance.
- Hand-written versus library. A 40-line
Result<T>is easy to own but easy to get subtly wrong (defaultas success, no async helpers). A library brings tested combinators and a dependency that every project layer now references. - A result can be ignored silently. Calling
Save(order);and dropping the returnedResultcompiles. An exception cannot be ignored by accident. Code review, or an analyzer the team adds, has to cover this. - Mixing styles is the real risk. A codebase where some use cases throw
NotFoundExceptionand others returnError.NotFoundneeds two translations at the boundary. Pick one convention per layer and write it down.
Documentation Links
- Best practices for exceptions, Microsoft Learn
- Exception throwing, Framework Design Guidelines, Microsoft Learn
- Exceptions and performance, Framework Design Guidelines, Microsoft Learn
- Handle errors in ASP.NET Core APIs, Microsoft Learn
- Results.Problem method, Microsoft Learn
- OneOf on GitHub
- ErrorOr on GitHub