Why Discriminated Unions Are the Future of Error Handling in C#

Why Discriminated Unions Are the Future of Error Handling in C#

Error handling in most C# codebases is a mess. We pretend exceptions are exceptional, scatter try-catch blocks throughout our code, and hope we've caught everything. There's a better way.

The Exception Problem

Exceptions have served us well, but they have fundamental issues:

1. They're Invisible

Look at this method signature:

public async Task<User> GetUserAsync(string userId)

What can go wrong? The signature doesn't tell you:

  • Network failures?
  • User not found (404)?
  • Authentication expired (401)?
  • Server errors (500)?
  • JSON parsing errors?
  • Timeout?

You have to read the documentation (if it exists) or the implementation to find out.

2. They're Easy to Forget

var user = await userService.GetUserAsync("123");
// If this throws, we crash
Console.WriteLine(user.Name);

The compiler happily accepts this code. No warning. No error. Just a runtime crash waiting to happen.

3. They Encourage Bad Patterns

When exceptions are your only error mechanism, you end up with:

try
{
    var user = await GetUserAsync(userId);
    var orders = await GetOrdersAsync(user.Id);
    var recommendations = await GetRecommendationsAsync(user.Id);
    return new Dashboard(user, orders, recommendations);
}
catch (HttpRequestException ex)
{
    // Which call failed? We don't know!
    return null;
}
catch (JsonException ex)
{
    // Deserialization failed... somewhere
    return null;
}
catch (Exception ex)
{
    // The "catch all" that catches nothing useful
    return null;
}

Enter Discriminated Unions

A discriminated union is a type that can be one of several distinct cases. In RestClient.Net:

Result<User, HttpError<ApiError>> result = await GetUserAsync(userId);

This type says: "I am either a successful User OR an error. I cannot be null. I cannot be something else."

The Result Type

public abstract record Result<TSuccess, TError>
{
    public sealed record Ok(TSuccess Value) : Result<TSuccess, TError>;
    public sealed record Error(TError Value) : Result<TSuccess, TError>;
}

Simple. Elegant. Complete.

The HttpError Type

For HTTP operations, we further distinguish error types:

public abstract record HttpError<TError>
{
    // Server returned an error response
    public sealed record ResponseError(
        TError Error,
        HttpStatusCode StatusCode,
        HttpResponseMessage Response
    ) : HttpError<TError>;

    // Exception occurred (network, timeout, etc.)
    public sealed record ExceptionError(
        Exception Exception
    ) : HttpError<TError>;
}

Now the type tells you exactly what can happen:

  • Success with a User
  • Error response from the server (with status code and body)
  • Exception during the request (with the exception)

Pattern Matching: The Key

C#'s pattern matching makes working with discriminated unions elegant:

var message = result switch
{
    OkUser(var user) => $"Welcome, {user.Name}!",
    ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => "User not found",
    ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => "Please log in",
    ErrorUser(ResponseErrorUser(var err, var status, _)) => $"Error {status}: {err.Message}",
    ErrorUser(ExceptionErrorUser(var ex)) => $"Network error: {ex.Message}",
};

Every case is explicit. The compiler knows all possibilities.

Exhaustiveness: The Game Changer

Here's where it gets powerful. With the Exhaustion analyzer, this code won't compile:

var message = result switch
{
    OkUser(var user) => user.Name,
    ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message,
    // COMPILE ERROR: Missing ExceptionErrorUser case!
};
error EXHAUSTION001: Switch on Result is not exhaustive;
Missing: Error<User, HttpError<ApiError>> with ExceptionError

Runtime crashes become compile-time errors.

Real-World Benefits

1. Self-Documenting Code

The return type tells you everything:

// This method can return a User, a 404 error, or a network exception
public Task<Result<User, HttpError<NotFoundError>>> GetUserAsync(string id)

2. Fearless Refactoring

Add a new error case? The compiler finds every place that needs updating:

// Added a new error type
public abstract record HttpError<TError>
{
    public sealed record ResponseError(...) : HttpError<TError>;
    public sealed record ExceptionError(...) : HttpError<TError>;
    public sealed record TimeoutError(...) : HttpError<TError>; // NEW!
}

Every switch statement that handles HttpError now shows a compile error until you handle TimeoutError.

3. Composable Operations

Chain operations that might fail:

var dashboard = await GetUserAsync(userId)
    .FlatMapAsync(user => GetOrdersAsync(user.Id))
    .MapAsync(orders => new Dashboard(orders));

If any step fails, the error propagates. No try-catch needed.

4. Better Testing

Test each case explicitly:

[Fact]
public async Task GetUser_ReturnsResponseError_WhenNotFound()
{
    var result = await service.GetUserAsync("unknown");

    var error = Assert.IsType<ErrorUser>(result);
    var responseError = Assert.IsType<ResponseErrorUser>(error.Value);
    Assert.Equal(HttpStatusCode.NotFound, responseError.StatusCode);
}

5. Cleaner Code Reviews

Reviewers don't need to ask "did you handle errors?" The compiler already verified it.

The Path Forward

C# is evolving toward better support for discriminated unions. But you don't have to wait:

  1. Use RestClient.Net for HTTP operations
  2. Install the Exhaustion analyzer for compile-time checking
  3. Create your own discriminated unions for domain logic

Example domain union:

public abstract record PaymentResult
{
    public sealed record Success(string TransactionId) : PaymentResult;
    public sealed record InsufficientFunds(decimal Available, decimal Required) : PaymentResult;
    public sealed record CardDeclined(string Reason) : PaymentResult;
    public sealed record Timeout(TimeSpan Duration) : PaymentResult;
}

Now payment handling is explicit, exhaustive, and documented in the type system.

Conclusion

Discriminated unions transform error handling from:

  • Invisible to explicit
  • Optional to required
  • Runtime crashes to compile-time errors

It's not just a different syntax. It's a fundamentally safer approach to building software.

Try RestClient.Net today and experience the future of error handling in C#.


Related: Functional Error Handling in C# | Getting Started