Stop Writing Boilerplate Conversions: The Result Pattern in C#
Every error type looked identical. Same two properties: error and description. Different type name.
I stared at the handler converting ValidationError to ProcessingError, copying those same two fields into a new type. Eight lines of boilerplate, for what? Simply because C# couldn't express "same error, different context."
Every handler looked almost the same: validate the request, then explicitly pattern-match the result. If validation failed, destructure the error and reconstruct an identical error with a different type name from another hierarchy of types. If validation succeeded, process the request. Don't forget the default case throwing UnexpectedTypeException, a runtime bomb that could detonate if anyone added a new result type and forgot to update every handler.
There had to be a better way.
The refactoring changed how I think about error handling in OAuth 2.0 and OpenID Connect, where one mishandled case can compromise security or break an auth flow. I'll share what stuck: the patterns I kept using afterwards, and the mistakes that taught them.
TL;DR
I migrated our OAuth 2.0/OIDC library from per-endpoint inheritance-based unions to a single generic Result type. The problem? About twenty duplicate error types, handlers bloated with boilerplate type conversions, and runtime type checks waiting for the day someone added a result type and missed a handler.
Here's what was at stake: Every new endpoint meant copy-pasting error handling code across four layers. Every handler risked UnexpectedTypeException if I forgot to update pattern matching. Code reviews became "spot the missing default case" exercises. Even I, as the author, had to re-trace the error hierarchy every time I touched a new endpoint. The mechanical work was dragging velocity down.
The Result pattern changed the economics. About twenty duplicate error types vanished, and every handler in the validate-then-process pipeline collapsed from 10-15 lines to just 2. The runtime type checks along that pipeline became compile-time guarantees. The work shipped as the core of our v2.0 release, a change that swept more than 300 files; the test suite stayed green throughout, and v2 went on to pass the same OpenID Foundation conformance profiles v1 had passed.
The trade-offs were real. Your team faces a learning curve with functional concepts. Type signatures grow longer in some places. The migration itself takes time and effort.
Would I do it again? Without hesitation. If you're working with rich error semantics - OAuth 2.0, payment systems, complex validation flows - this pattern transforms how you handle errors.
Where I Started
To understand the impact of my migration, you need to see how Abblix OIDC Server is designed under the hood.
Each OAuth 2.0/OIDC endpoint follows a consistent pipeline:
Validators examine incoming requests for OAuth 2.0/OIDC compliance. Invalid credentials, missing parameters, expired tokens: validators catch these and return either a validated request or validation errors.
Processors execute the business logic - generating tokens, retrieving user info, managing sessions, revoking credentials. They consume validated requests and return either success results or processing errors.
Handlers orchestrate the flow: they coordinate validators and processors and manage the request-response lifecycle for each endpoint.
Formatters convert results into HTTP responses. Success usually becomes 200 OK with a JSON payload. Errors become 400 Bad Request (client errors), 401 Unauthorized, or 403 Forbidden (auth failures), with standardized error details.
Clean. Testable. Hexagonal.
What no diagram shows: the mechanical work of passing errors through each stage. Validators returned ValidationError. Processors needed ProcessingError. Same two fields, copied manually at every boundary, not because the architecture was wrong, but because C# forced separate types in each hierarchy.
The Problem I Was Solving
Every component needed to communicate success or failure between stages, and my discriminated union implementation created massive duplication at every level.
OAuth 2.0 and OpenID Connect servers are full of complex error scenarios across multiple endpoints. My original implementation used inheritance-based discriminated unions - each endpoint had a base response type with success and error subtypes.
But here's what made it painful in my case: every endpoint had two separate type hierarchies. One for validation (ValidRequest/ValidationError) and another for processing (SuccessResponse/ErrorResponse). This meant:
- About twenty duplicate error types, all containing identical properties
- Forced type conversions: handlers manually converted
ValidationErrortoProcessingError, pure boilerplate - 10-15 line handlers: just to pass errors through validation, processing, and formatting
- Runtime exceptions: pattern matching required default cases that threw
UnexpectedTypeException
The root cause? The C# we were building on had no native discriminated unions. Unlike F#, TypeScript, or Rust, it had no sum types built into the language, so the codebase simulated discriminated unions with inheritance, a verbose workaround. (That gap is closing in C# 15, which a later section takes up.)
The Inheritance-Based Approach
// Base type for discriminated union
public abstract record UserInfoResponse;
// Success case
public record UserInfoFoundResponse(JsonObject User, ClientInfo ClientInfo, string Issuer)
: UserInfoResponse;
// Error case - identical structure across all endpoints!
public record UserInfoErrorResponse(string Error, string ErrorDescription)
: UserInfoResponse;
// Handler signature - doesn't reveal this can fail
Task<UserInfoResponse> HandleAsync(UserInfoRequest request);
This pattern repeated across every endpoint.
You might wonder: why not one base type for all endpoints? Because OAuth 2.0 endpoints return fundamentally different success types: UserInfo returns claims, Token returns access and refresh tokens, Revocation returns nothing, Introspection returns token metadata. Separate base types weren't a design mistake; they reflected the domain. The same goes for the split between validation and processing stages, which are genuinely different concerns.
In inheritance-based unions, the base type defines the union's scope: you can't mix types from different hierarchies.
The next idea is usually to share one base type across all pipeline stages. It would work only if the type system distinguished between stages, but C# doesn't.
public abstract record PipelineResult;
public record ValidRequest(...) : PipelineResult;
public record OidcError(string Error, string ErrorDescription) : PipelineResult;
public record SuccessResponse(...) : PipelineResult;
Task<PipelineResult> ValidateAsync(...); // Should be: ValidRequest | OidcError
Task<PipelineResult> ProcessAsync(ValidRequest); // Should be: SuccessResponse | OidcError
Both methods return the same PipelineResult, so the compiler can't catch a processor that accidentally returns a ValidRequest. Sharing base types produces unions that are too loose, including combinations that should be impossible. The same problem appears if you try to share a base type across different endpoints: UserInfoSuccess and TokenSuccess under a common parent means the UserInfo handler is now type-compatible with the Token handler's return, and vice versa.
I needed precise unions:
- Validation union:
ValidRequest | ValidationError - Processing union:
SuccessResponse | ProcessingError
Two different unions with different valid combinations. C# has no way to express "semantically identical types, different unions." The single-inheritance constraint forces separate error types per stage, even though they carry identical properties (Error, ErrorDescription). The handler sat in the middle, copying those properties from ValidationError to ProcessingError.
I spent significant time trying to make shared base types work before I accepted this constraint.
The Handler Complexity
A typical handler:
public async Task<UserInfoResponse> HandleAsync(
UserInfoRequest userInfoRequest,
ClientRequest clientRequest)
{
var validationResult = await _validator.ValidateAsync(userInfoRequest, clientRequest);
return validationResult switch
{
ValidUserInfoRequest validRequest => await _processor.ProcessAsync(validRequest),
UserInfoRequestError { Error: var error, ErrorDescription: var description }
=> new UserInfoErrorResponse(error, description),
_ => throw new UnexpectedTypeException(nameof(validationResult), validationResult.GetType()),
};
}
More than 10 lines of code to express a simple flow: validate, then either process or return an error.
The middle case is pure boilerplate - destructuring Error and ErrorDescription from UserInfoRequestError just to construct an identical UserInfoErrorResponse.
And that default case with UnexpectedTypeException? It fails only at runtime. If you add a new validation result type and forget to update the handler, the compiler won't catch it. You'll only discover the bug when that code path executes in production.
This pattern repeated across many handlers. The Token endpoint had TokenError to TokenErrorResponse. The Revocation endpoint had RevocationError to RevocationErrorResponse. You get the idea. Every handler performed the same mechanical job.
The formatters had the same problem - explicit pattern matching with default exception cases everywhere.
Why Not Result From Day One
A fair question I get from peer reviewers: why did I ship v1 on inheritance-based discriminated unions, when I knew about the Result pattern back when Oidc.Server started in late 2022 and functional error handling was already well-established?
Two honest reasons.
First, I didn't feel the pain yet. The codebase was small. A few endpoints, a handful of error types. Pattern matching with default-case-throws felt like the standard C# idiom, and most .NET projects I'd worked on used exactly that. I knew Result existed. I didn't have a concrete moment that made me think "this would have prevented today's bug." Without that pressure, the inheritance-based approach was the path of least resistance.
Second, I deliberately limited novelty. Starting an OIDC server from scratch was already a large unknown: six conformance profiles, dozens of edge cases per spec, all of it security-critical. Adding "and the error model is something I've never shipped to production" multiplies risk on the wrong axis. I wanted v1 to land in a reasonable timeframe, and familiar idioms were part of that velocity, not a deficit.
The migration came when both reasons inverted. The codebase had grown to more than a dozen endpoints, and the cost of inheritance-based DU was no longer hypothetical: every new endpoint cost me an hour of mechanical boilerplate. By that point, I'd absorbed enough of the codebase to take on a deeper refactor. Conditions changed; the answer changed.
If I were starting Oidc.Server today, knowing what I know now? I'd probably reach for the Result pattern from the first endpoint. But that's the privilege of hindsight, not a critique of the original choice. v1 shipped on time and passed OpenID Foundation certification. The migration to v2 happened when it was the right move, not earlier and not later.
Understanding Result Pattern
A quick primer before the code. Result is a functional-programming approach to error handling. Instead of throwing exceptions or returning null, a method returns an explicit type: success with a value, or failure with an error. Nothing else. No nulls and no exceptions. The type system forces you to handle both paths.
The critical part: you can't casually access the success value without handling the failure case. The success value is sealed inside the Result type, and the idiomatic path out is through operations like Match and Bind that force you to deal with both outcomes (escape hatches like TryGetSuccess exist for tests and boundaries). You rarely have to wonder "did I forget to check for errors?"
Think of it as a railway with two tracks:
- Success track: If the current operation succeeded, execute the next operation.
- Failure track: If the current operation failed, skip all subsequent operations and propagate the error.
Abblix Implementation
You might ask why roll your own at all. The .NET ecosystem has solid Result libraries: ErrorOr, FluentResults, LanguageExt, OneOf. The honest answer is that we already had this type, proven in production in Abblix Account, before the migration began. Swapping working code we owned for a third-party dependency would have added external surface to a security library for no functional gain. If you are starting fresh without one, any of those libraries will do; nothing in this article is specific to ours.
Its shape:
public abstract record Result<TSuccess, TFailure>
{
public static implicit operator Result<TSuccess, TFailure>(TSuccess value) => new SuccessResult(value);
public static implicit operator Result<TSuccess, TFailure>(TFailure failure) => new FailureResult(failure);
public abstract T Match<T>(Func<TSuccess, T> onSuccess, Func<TFailure, T> onFailure);
public abstract Result<TNext, TFailure> Bind<TNext>(Func<TSuccess, Result<TNext, TFailure>> func);
public abstract Task<Result<TNext, TFailure>> BindAsync<TNext>(Func<TSuccess, Task<Result<TNext, TFailure>>> func);
private sealed record SuccessResult(TSuccess Value) : Result<TSuccess, TFailure>;
private sealed record FailureResult(TFailure Failure) : Result<TSuccess, TFailure>;
}
Implicit conversions. Processors just return new OidcError(...) on failure or return new TokenIssued(...) on success. The type system wraps them. No Result.Success() or Result.Failure() ceremony.
Composition with Bind. Bind chains operations that return Results. If validation succeeds, processing continues. If it fails, BindAsync propagates the error automatically:
var validationResult = await _validator.ValidateAsync(request);
return await validationResult // Result<ValidRequest, OidcError>
.BindAsync(_processor.ProcessAsync); // runs only on success
Pattern matching with Match. The compiler enforces exhaustiveness. You must handle both cases. No default branches, no runtime exceptions:
return response.Match(
onSuccess: success => FormatSuccess(success),
onFailure: error => FormatError(error));
Additional operations. MapSuccess/MapFailure transform values without changing the Result shape; Ensure adds a predicate that converts success to failure; TryGetSuccess/TryGetFailure extract safely; Deconstruct plugs into C# deconstruction. Together these let you chain validations, transformations, and async operations while errors propagate automatically through the pipeline.
The Migration Steps
The first step was clear: I needed to establish the foundation. I couldn't touch handlers until I had a solid error type to rely on.
I defined a single OidcError record to replace all those duplicate error types:
// One error type to rule them all
public record OidcError(string Error, string ErrorDescription);
Simple, right? I kept specialized types only where they actually add value:
public record AuthorizationRequestValidationError(string Error, string ErrorDescription, Uri? RedirectUri, string ResponseMode)
: OidcError(Error, ErrorDescription);
Why keep this? Because authorization errors need additional properties (RedirectUri and ResponseMode) that generic OidcError doesn't have. This is legitimate specialization - the error carries context-specific information needed for OAuth 2.0 redirect flows.
This is where the security stakes get concrete. An authorization error has to travel back to the client through the registered redirect_uri, encoded in the response_mode the request asked for. The exception is when the redirect_uri itself is what failed validation: redirecting then would be an open redirect, handing an attacker a way to bounce victims off our domain. The error type carries exactly the context that decides "redirect with the error" versus "stop and show it here." When that decision rode on a default case in a switch, a missed branch would not have been a 500 but a potential security hole. In v1 that guarantee rested on tests and review, and it held; exhaustive matching moves it from runtime discipline to a compile-time guarantee, which in an OAuth server is not a nicety.
Here's the final error hierarchy:
OidcError (base: Error, ErrorDescription)
├── BackChannelAuthenticationUnauthorized → HTTP 401
├── BackChannelAuthenticationForbidden → HTTP 403
└── AuthorizationRequestValidationError → HTTP 400
├── Error, ErrorDescription (inherited)
├── RedirectUri (additional)
└── ResponseMode (additional)
I deleted about twenty duplicate error types in this phase. That alone was worth the effort.
Once the foundation was solid, I started migrating endpoints to it. And I discovered that every endpoint followed the exact same transformation pattern.
Update Interface Signatures
// Before - what does this return?
Task<UserInfoResponse> HandleAsync(UserInfoRequest request);
// After - explicitly a success-or-failure operation
Task<Result<UserInfoFoundResponse, OidcError>> HandleAsync(UserInfoRequest request);
The new signature is self-documenting. Anyone reading it knows there are exactly two cases and what they are: either UserInfoFoundResponse on success or OidcError on failure.
Simplify the Handlers
The payoff:
// Before - explicit pattern matching with runtime exception
var validationResult = await _validator.ValidateAsync(userInfoRequest, clientRequest);
return validationResult switch
{
ValidUserInfoRequest validRequest => await _processor.ProcessAsync(validRequest),
UserInfoRequestError { Error: var error, ErrorDescription: var description }
=> new UserInfoErrorResponse(error, description),
_ => throw new UnexpectedTypeException(nameof(validationResult), validationResult.GetType()),
};
// After - just 2 lines that do the same thing
var validationResult = await _validator.ValidateAsync(userInfoRequest, clientRequest);
return await validationResult.BindAsync(_processor.ProcessAsync);
That BindAsync call? It's railway-oriented programming in action. If validation fails, the error propagates automatically. On success, the processor runs next. No manual error mapping needed.
Update Processors
Processors became simpler too. Thanks to implicit conversions, they just return success values directly:
public async Task<Result<UserInfoFoundResponse, OidcError>> ProcessAsync(...)
{
var user = await GetUserInfo(...);
return new UserInfoFoundResponse(user, clientInfo, issuer);
// Implicitly converts to Result<UserInfoFoundResponse, OidcError>
}
No wrapping, the type system handles it.
Clean Up Formatters
Formatters got the Match treatment:
// Before - explicit switch with runtime exception risk
return response switch
{
UserInfoFoundResponse found => FormatSuccess(found),
UserInfoErrorResponse error => FormatError(error),
_ => throw new UnexpectedTypeException()
};
// After - exhaustive, compile-time checked
return response.Match(
onSuccess: FormatSuccess,
onFailure: FormatError);
Both cases are handled or the code doesn't compile. No runtime surprises.
Delete the Base Type
The final satisfaction: deleting UserInfoResponse entirely. No more discriminated union base types needed.
I repeated this pattern across all the endpoints. By the third one, I could do it in my sleep, and that sameness is what made each migration diff small, atomic, and easy to review.
Let the Compiler Guide You
After updating interfaces, I just built the project and fixed errors systematically. Each error was a clear signpost showing exactly what needed fixing. I worked from the bottom up: interfaces, then implementations, then tests, one endpoint at a time, keeping the suite green after each step.
The Challenges
Nothing's perfect. The migration had real costs.
The Learning Curve
Terms like monad and railway-oriented programming sound intimidating. Once you get past terminology, Result is simpler than what I had before. The real challenge isn't new complexity, it's unlearning OOP patterns I had convinced myself were normal because I had used them for years. Inheritance hierarchies with base types and switch expressions with default cases throwing exceptions are not simple. They feel simple only because they are familiar.
The mental shifts were more significant than the code changes. Exceptions stopped being control flow and became the mechanism for truly unexpected failures. Validation failures? Expected: use Result. Database down? Unexpected: throw. When a signature returns Result<User, Error>, null is unrepresentable (nullable annotations enforce it), so the defensive null checks had nothing left to defend and disappeared.
The biggest shift was replacing inheritance hierarchies with composition through BindAsync. Reading Scott Wlaschin's essays on railway-oriented programming was the breakthrough moment; after that I started seeing dual-track patterns everywhere in my codebase.
Longer Type Signatures
This is a real trade-off:
// Before
Task<UserInfoResponse>
// After
Task<Result<UserInfoFoundResponse, OidcError>>
The type signature got longer. In deeply nested generics, it can get verbose:
Task<Result<IReadOnlyList<Result<ValidClient, OidcError>>, OidcError>>
My solution? Type aliases:
using ValidationResult = Result<ValidRequest, OidcError>;
Task<ValidationResult> ValidateAsync(...);
Much more readable.
Multiple Error Types
Result holds one error type. What if an operation can fail with database errors or validation errors? A common solution is a shared discriminated union base (public abstract record OperationError) with derived types. Another is nested Result<Result<...>>, which gets unreadable fast. I chose a third path: Result for expected failures (validation, auth) that map to 4xx status codes, exceptions for unexpected failures (database down, network timeout) that map to 5xx. Expected outcomes flow as values; system failures flow as exceptions.
Minor Trade-offs
No stack traces for Result errors. Result doesn't capture a call stack. That's fine: expected failures like "missing parameter" don't need one; the error code and description tell you what happened. Stack traces are valuable for unexpected failures, which remain exceptions.
C# doesn't enforce handling. Unlike Rust's #[must_use], C# lets you ignore a returned Result<T, E> with no compiler error: the limitation applies to any return value in C#, not specifically Result, and C# has no built-in must-use enforcement, so code review carries this one. The idiomatic operations, Match and Bind, do force you to acknowledge both paths; the TryGet* and Deconstruct escape hatches are for tests and boundaries, where the discipline stays on you.
Allocation cost. Result<TSuccess, TFailure> is a class-based record, so every pipeline stage allocates a small wrapper object, where struct-based libraries like ErrorOr and OneOf avoid the allocation. That is a deliberate choice - records keep the type simple, avoid large-struct copies, and sidestep default-struct pitfalls - and in an OAuth server the cost disappears into the noise: every request is dominated by I/O and cryptography, and the short-lived wrappers die in gen0. If you are adopting Result on a hot in-memory path, measure first; the struct-based libraries exist for exactly that case.
What I Gained
I approached the migration incrementally: one endpoint at a time, compiler-guided, with atomic commits I could revert. It touched almost every layer, and a large share of the code in those files simply disappeared, not because I cut corners but because it was boilerplate the Result pattern made obsolete.
Three things changed for good. The twenty-odd near-identical error types collapsed into one OidcError with a few specialized variants. Every pipeline handler shrank to two lines. And along the Result-shaped pipeline, the default cases that used to throw at runtime became compile-time exhaustiveness: forget a case there now and it does not compile. (The authorize endpoint's multi-shape responses keep a discriminated union with its guarded matching: the residue the C# 15 section returns to.) Type safety moved from runtime hope to a guarantee the compiler checks.
Automatic Error Propagation
Railway-oriented programming made the pipeline simple:
Success Track: Validate → Process → Format → Return 200
↓ ↓ ↓
Failure Track: Error ────→ Error ──→ Error → Return 4xx
On success, the processor runs; on failure, the error propagates automatically without running the processor. No manual error checking. No if or switch statements. Errors flow automatically.
The first time I saw a validation error skip straight past processing to the error response without a single if statement, I knew the migration was going to pay off.
Unified Error Type Across Pipeline
The same OidcError now flows through every stage:
Validator → Result<Valid, OidcError>
↓
Processor → Result<Success, OidcError> // Same OidcError!
↓
Formatter → ActionResult
No more forced type conversions between ValidationError and ProcessingError. The same OidcError flows from validation through processing to formatting. That's what a unified error type buys you.
In languages with native discriminated unions, this unification is built in. In the C# versions we target, the Result pattern provides it.
Simpler Testing
Before, I had to do type checks in tests:
var response = await handler.HandleAsync(request);
Assert.IsInstanceOfType<UserInfoErrorResponse>(response);
var error = (UserInfoErrorResponse)response;
Assert.AreEqual("invalid_token", error.Error);
After, TryGetFailure extracts the failure directly, no casts and no type checks:
var result = await handler.HandleAsync(request);
Assert.IsTrue(result.TryGetFailure(out var error));
Assert.AreEqual("invalid_token", error.Error);
Patterns That Emerged
I now reach for these templates on every new endpoint.
The Universal Handler Template
Every pipeline handler (validate, then process) now looks like this:
public async Task<Result<TSuccess, OidcError>> HandleAsync(TRequest request)
{
var validationResult = await _validator.ValidateAsync(request);
return await validationResult.BindAsync(_processor.ProcessAsync);
}
Two lines. Every time. The one deliberate outlier is the authorize endpoint, whose multi-shape response is exactly the case "Multiple Success Types" below is about.
When all handlers look the same, you can focus on what makes each endpoint unique (the validation and processing logic), rather than the wiring that carries errors between stages.
The Formatter Match Pattern
All formatters follow this structure:
public Task<ActionResult> FormatResponseAsync(
TRequest request,
Result<TSuccess, OidcError> response)
{
return Task.FromResult(response.Match(
onSuccess: success => FormatSuccess(request, success),
onFailure: error => FormatError(error)));
}
private ActionResult FormatSuccess(TRequest request, TSuccess success)
{
// Success formatting logic
}
private ActionResult FormatError(OidcError error)
{
// Status selection by error subtype elided; see the hierarchy above
return new BadRequestObjectResult(
new ErrorResponse(error.Error, error.ErrorDescription));
}
Extract formatting logic into separate methods. Keep Match calls clean.
Implicit Conversion Pattern
Skip the explicit Result.Success() and Result.Failure() wrappers. The implicit operators on Result<TSuccess, TFailure> let processors just return the domain value or the error directly, and the type system wraps them. The signature announces what the method does; the body doesn't need to repeat it.
Error Type Inheritance Only for Behavior
Only derive from OidcError when the subtype carries real differentiation: a distinct HTTP status code (BackChannelAuthenticationUnauthorized -> 401, BackChannelAuthenticationForbidden -> 403) or additional properties the caller needs (AuthorizationRequestValidationError adds RedirectUri and ResponseMode). Don't create hierarchies for organizational purposes.
When NOT to Use Result Pattern
Result isn't a silver bullet.
Operation Always Succeeds
// Bad: Unnecessary
public Result<int, Error> Add(int a, int b) => a + b;
// Good
public int Add(int a, int b) => a + b;
If it can't fail, don't use Result.
Failure Is Truly Exceptional
// Bad: Overkill
public Result<UserData, DatabaseError> GetUser(int id);
// Good - database unavailability is exceptional
public UserData GetUser(int id)
{
// Throws SqlException if database is down
}
Database failures are unexpected. Use exceptions for unexpected failures and let them propagate up the call stack.
Multiple Success Types
If you need to return different success types, use discriminated unions (inheritance):
// Good: Discriminated union is appropriate here
public abstract record AuthorizationResponse;
public record AuthorizationSuccess(...) : AuthorizationResponse;
public record AuthorizationRedirect(...) : AuthorizationResponse;
public record AuthorizationError(...) : AuthorizationResponse;
Result is for binary success or failure. Multiple success types still need discriminated unions.
Integrating with Exception-Based APIs
Don't wrap everything in Result just for consistency:
// Bad: Unnecessary boilerplate
public Result<UserData, Error> GetUser(int id)
{
try
{
return _database.GetById(id); // throws SqlException
}
catch (SqlException ex)
{
return new Error(ex.Message);
}
}
// Good - let exceptions propagate
public UserData GetUser(int id)
{
return _database.GetById(id);
}
Introduce Result only where an expected, domain-level failure needs to travel as a value, not as a blanket wrapper around every exception-based API.
What C# 15's Union Types Change
If you follow the language, you can probably see the obvious objection coming: C# is getting native unions. Type unions ship in C# 15 with .NET 11, previewed in April 2026 and headed for general availability that November. The union keyword finally gives C# what F#, Rust, and TypeScript have had for years. So does that make this whole migration a workaround for a gap that is about to close?
Not really, and the reason clarifies what Result actually is.
Native unions and Result are not different problems so much as two levels of the same one. A union expresses "this value is exactly one of a closed set of types," and the compiler checks that you handle every case. That is exactly the right tool for the places this article tells you not to use Result. An authorization response that is success, redirect, or error is three genuinely different shapes, and a native union will express that more cleanly than the inheritance-based discriminated union I simulate today.
Result adds something the union keyword does not provide on its own. It is the binary success-or-failure shape plus the railway composition layered on top: Bind chains the next step only on success, Match forces both branches, errors short-circuit the pipeline automatically. Could you write those same Bind and Match methods over a C# 15 union? Of course, and you would have to: the keyword hands you the shape and exhaustive matching, but the composition is library code either way. That composition, BindAsync(_processor.ProcessAsync) collapsing a handler to two lines, is the payoff of this migration, and it lives in the library layer, not in the shape underneath it.
So the two are complementary. When C# 15 lands I expect to reach for native unions exactly where I reach for inheritance-based discriminated unions today: the multiple-success-shape cases above. Result stays the success-or-error spine of every endpoint, whatever shape sits under it. The migration was never a bet against the language catching up. It was a bet on railway-oriented composition, which the language is not adding.
Could a future major that requires .NET 11 rebuild Result on a native union and delete the hand-rolled SuccessResult and FailureResult records? Out of the box a union is not a full replacement: it gives you the shape and exhaustive matching, not the Bind and Match composition that does the real work. Write those over the union and the two level out; what the native side then adds is that the hand-rolled wrapper records disappear and the one switch inside Match becomes compiler-verified. The usual objection, that an untagged TSuccess | TFailure cannot tell the two apart when success and failure are the same type, does not bite us: a success payload and an OidcError are never the same type, so a native union discriminates our cases directly. If your domain can put the same type on both tracks, the objection does bite (today's implicit conversions collide the same way), so keep the error type distinct.
There is a practical gap too. C# 15 unions are a language and runtime feature, so a library can only put them in its public API by targeting .NET 11 and dropping the frameworks it still supports. We multi-target .NET 8, 9, and 10 today, and .NET 10 is itself an LTS supported into 2028. Result is just a type. It works on all of them now, with no framework or language version to wait for.
That shapes the road ahead. .NET 8 and .NET 9 both retire in November 2026, the same month .NET 11 and C# 15 reach general availability. The likely path is to settle the 2.x line on .NET 10, supported through that LTS into 2028, and build a 3.0 on .NET 11 once those ship in stable form, not on the previews. That 3.0 is most likely where we adopt C# 15 unions as the substrate, with the Bind and Match composition kept on top: the success-or-error spine unchanged, the plumbing underneath it modernized.
Recommendations
Document Result vs. Exception Guidelines
The dual approach needs clear boundaries. Document when each mechanism applies or the team will rediscover the distinction in every code review.
Result works best for domain-level failures - the kind where you want to return a specific error code to the client. Validation failures become 400 Bad Request, authentication failures become 401 Unauthorized, authorization failures become 403 Forbidden. These are expected business outcomes, not exceptional circumstances.
Exceptions remain appropriate for infrastructure failures - situations where the system itself is compromised. Database unavailable, network timeout, missing configuration, programming bugs - these map to 500-level status codes because they indicate something went wrong with the system, not the request.
Create Helper Extensions
Beyond the core Result type, I created extension methods for common patterns:
Ensure - Inline Validation
Convert a plain value into a Result with validation, the plain-value counterpart of the member Ensure on Result itself:
public static Result<TSuccess, TFailure> Ensure<TSuccess, TFailure>(
this TSuccess value,
Func<TSuccess, bool> predicate,
TFailure failure)
{
return predicate(value) ? value : failure;
}
public static async Task<Result<TSuccess, TFailure>> EnsureAsync<TSuccess, TFailure>(
this Task<TSuccess> valueTask,
Func<TSuccess, bool> predicate,
TFailure failure)
{
var value = await valueTask;
return value.Ensure(predicate, failure);
}
Usage:
var result = clientId.Ensure(
id => !string.IsNullOrEmpty(id),
new OidcError("invalid_client", "Client ID is required"));
FailIfNull - Null Safety
Converts a nullable into a Result, with a corresponding FailIfNullAsync for Task<T?>:
var result = await _repository.FindUserAsync(userId)
.FailIfNullAsync(new OidcError("user_not_found", "User does not exist"));
BindAsync for Task<Result>
Chains operations when you already have a Task<Result>, so you don't await in the middle of a pipeline:
return await _validator.ValidateAsync(request)
.BindAsync(_processor.ProcessAsync)
.BindAsync(_enricher.EnrichAsync);
Avoid Nested Results
Don't nest Results unless absolutely necessary. Instead of Result<Result<Success, ValidationError>, AuthenticationError>, flatten with a common error base:
public abstract record OperationError;
public record ValidationError(...) : OperationError;
public record AuthenticationError(...) : OperationError;
Result<Success, OperationError>
(Both are expected, domain-level failures. Infrastructure errors stay exceptions, per the guideline above.)
Further Reading
- Railway Oriented Programming by Scott Wlaschin: the definitive guide that introduced the railway metaphor.
- Functional Error Handling in C# by Vladimir Khorikov: functional error handling patterns adapted to C#.
- Result in Rust: first-class Result in a language that enforces handling.
Summary
Method signatures became self-documenting contracts. The compiler caught, during the migration itself, the kind of mistakes that would otherwise have surfaced only in testing. For a domain where a mishandled error can leak the wrong response down the wrong channel, that shift from runtime hope to compile-time guarantee is the real return.
That's the better way I was looking for.
Try It Yourself
If you're working with OAuth 2.0, OpenID Connect, or any domain with rich error semantics, try this approach.
Start small: pick one feature with complex error handling, follow the migration steps above, measure the impact.
You don't have to build the Result type yourself, either. The same Result<TSuccess, TFailure> this migration runs on is published as Abblix.Utils on NuGet, the type that proved itself across the Abblix OIDC Server codebase. Pull it in (or reach for ErrorOr or FluentResults; the patterns transfer) and Bind, Match, Ensure, and the rest are already written, so you start on the patterns, not the plumbing.
- Abblix OIDC Server on GitHub: Result pattern in action across 200+ files
- Abblix.Utils on NuGet: the open-source Result implementation
The worst thing you can do is continue writing boilerplate error conversions. Try it on one feature. Make an informed decision.