Discriminated unions in C# and how they enhance error handling scenarios
In C# 15, we’ve got a new preview feature – union types. It was a highly awaited feature, widely used by F# and Rust developers. One area it can improve much is error handling. Before, in C#, there was one way of dealing with failures – exception handling. But this approach had some disadvantages. Let’s […]
Technologies
In C# 15, we’ve got a new preview feature – union types. It was a highly awaited feature, widely used by F# and Rust developers. One area it can improve much is error handling.
Before, in C#, there was one way of dealing with failures – exception handling. But this approach had some disadvantages. Let’s consider an example: A signature like Task<User> GetUserAsync(Guid id) promises us that it will return a User instance, but the method may throw UserNotFoundException or UserDeactivatedException, and it is not clear from the method definition. We need to read the implementation of a method to understand which exceptions it might throw in order to properly catch them.
Here unions are coming to the rescue. A union declares a set of types, so a method can return any of the types specified in union. Pattern matching over it is exhaustive, so the compiler forces us to provide an implementation for each possible case. With the union types enabled, it becomes possible to introduce a new way of handling success and failure cases known as the “Result” pattern, different from usual exception handling. The core idea of the “Result” pattern is that a method can return either a success value or a failure value. The return type makes it obvious that a method can fail.
Here is an example of a result union implementation:
// Union type definition
public union Result<TValue> (TValue, Error)
And let’s consider a method that can return either a success or failure value:
public async Task<Result<User>> GetUserAsync(Guid id)
{
var user = await _repository.FindAsync(id);
if (user is null) return new Error(“user.not_found”);
if (user.IsDeactivated) return new Error(“user.deactivated”);
return user;
}
Finally, with exhaustive pattern matching, we are forced to think about both success and failure scenarios, because otherwise the program won’t even compile.
Here is the example:
var response = await GetUserAsync(id) switch
{
User user => Ok(user),
Error error => Problem(error.Code)
};
As you can see from the example, inside switch expression, compiler forces us to provide an implementation for both success and failure scenarios. It is different from exception handling, because with exceptions we don’t have guarantees from the compiler and the program will compile even when we don’t specify possible exception handling scenarios.
Generally, it is a good approach to move possible failures from runtime to compile time; that’s why I think the “Result” pattern will be adopted also by C# developers over time. Still, the “Result” pattern is not a full replacement for all kinds of exceptions.
Exceptions remain the right tool for broken invariants, infrastructure failures, and cancellation. A simple rule of thumb: if the caller is expected to recover from failure, return a Result; if it can only give up, throw an exception. The “Result” pattern existed in C# for years through libraries like OneOf and custom implementations. What unions change is that the compiler now enforces the discipline, and error handling moves from runtime to a compile-time guarantee.
At Swan Software Solutions, we have a great team that uses the best tools to help our clients succeed. Find out more about how our team could help your team with its technology needs.