When developing applications in C#, methods often need to return more than one possible outcome. An operation might return the expected data, indicate that a requested record does not exist, or report that a business rule was not satisfied.
A common approach is to use object as the return type. Although this provides flexibility, it also creates uncertainty because the calling code cannot determine all possible return values from the method signature.
Developers often solve this problem using custom response classes, interfaces, inheritance, or exceptions. These approaches are useful, but they can introduce additional code and make it difficult to represent a fixed set of unrelated types.
C# 15 introduces native union types, providing a more structured way to model multiple possible outcomes. With union types, developers can define exactly which types a value can contain and use pattern matching to handle each possibility.
In this article, we will understand union types in C#, explore how they work, and implement practical examples for handling success and failure in application development.
Why Returning object Can Become a Problem
Consider a method that retrieves a customer from a database. It may return customer information when the operation succeeds or an error message when the requested customer does not exist.
One simple implementation is to return object.
public object FindCustomer(int customerId)
{
var customer = _repository.GetById(customerId);
if (customer == null)
return "Customer not found";
return customer;
}
At first glance, this implementation looks simple. However, the method can return completely different types.
The calling code must determine the actual type before accessing the returned value.
object result = FindCustomer(101);
if (result is Customer customer)
{
Console.WriteLine(customer.Name);
}
else if (result is string error)
{
Console.WriteLine(error);
}
This creates several problems:
The method signature does not clearly communicate its possible return values.
Developers must perform runtime type checks.
An unexpected return type can introduce runtime errors.
Adding another return type requires updating the calling code.
The compiler cannot guarantee that every possible outcome is handled.
The main issue is not that object is always wrong. The problem is that it does not provide a clear contract for methods that return a known set of possible results.
This is where union types become useful.
Understanding Union Types in C#
A union type represents a value that can contain exactly one value from a predefined set of types.
For example, a customer lookup operation might produce one of these outcomes:
A customer was found.
The customer does not exist.
The supplied customer ID is invalid.
The customer service is unavailable.
Instead of returning different objects through a common object type, a union declares all supported alternatives.
C# 15 introduces the union keyword for declaring these types.
Consider a simple example:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
The Pet union can contain a Cat, Dog, or Bird.
You can assign any of these types directly:
Pet pet = new Dog("Rocky");
The compiler provides implicit conversions from the declared case types to the union type.
More importantly, the compiler understands which types belong to the union. This allows switch expressions to check whether every declared case has been handled.
Union types are available with C# 15 and the .NET 11 SDK. Projects targeting net11.0 use C# 15 by default.
Implementing Success and Failure With Union Types
Let's implement a practical customer lookup example.
Step 1. Define the Customer Model
First, create a simple customer model.
public sealed record Customer(
int Id,
string Name,
string Email
);
Using a record keeps the model concise and provides value-based equality.
Step 2. Define the Possible Outcomes
Next, create separate types for each possible result.
public sealed record CustomerNotFound(int CustomerId);
public sealed record InvalidCustomerId(int CustomerId);
public sealed record CustomerStoreUnavailable(string Reason);
Each type represents a specific business or operational outcome.
For example, CustomerNotFound indicates that the requested customer does not exist. InvalidCustomerId represents invalid input, while CustomerStoreUnavailable describes an infrastructure problem.
Now define the union:
public union CustomerLookupResult(
Customer,
CustomerNotFound,
InvalidCustomerId,
CustomerStoreUnavailable
);
The declaration establishes four possible outcomes. A CustomerLookupResult can contain one of these alternatives.
This is more descriptive than returning object because the method communicates its supported return types directly.
Step 3. Return the Union From a Method
Now implement the customer lookup operation.
public CustomerLookupResult FindCustomer(int customerId)
{
if (customerId <= 0)
return new InvalidCustomerId(customerId);
var customer = _repository.GetById(customerId);
if (customer is null)
return new CustomerNotFound(customerId);
return customer;
}
Notice that the method does not need to create a separate response object or manually set a success flag.
Each returned value is implicitly converted into the declared union type.
The method remains simple, and every possible outcome has a meaningful type.
In a real application, database exceptions should also be handled at an appropriate boundary and mapped to a defined failure outcome when the application intends to return them as part of its contract.
Step 4. Handle Results Using Switch Expressions
Returning a union is only one part of the implementation. The calling code must also handle its possible outcomes.
var result = FindCustomer(101);
string message = result switch
{
Customer customer =>
$"Customer found: {customer.Name}",
CustomerNotFound error =>
$"Customer {error.CustomerId} was not found.",
InvalidCustomerId error =>
$"Invalid customer ID: {error.CustomerId}",
CustomerStoreUnavailable error =>
$"Customer service unavailable: {error.Reason}"
};
Console.WriteLine(message);
This is where union types become particularly useful.
The switch expression handles every declared case. If another case is added to the union later, the compiler can identify switch expressions that need updating.
This reduces the chances of introducing incomplete handling when application requirements change.
Using Generic Result Types for Reusable Operations
The previous example is useful when an operation has a specific set of outcomes. However, applications often have multiple methods that return different data types but follow the same success-or-failure pattern.
For example:
Customer lookup returns a customer.
Order processing returns an order ID.
Payment processing returns a transaction reference.
Product lookup returns product information.
Creating a separate union for every operation can introduce unnecessary repetition.
A generic result union provides a reusable solution.
public sealed record AppError(
string Code,
string Message
);
public union Result<T>(T, AppError);
Now the same result type can represent different successful values while maintaining a consistent error contract.
For example:
public Result<Customer> GetCustomer(int id)
{
var customer = _repository.GetById(id);
if (customer is null)
{
return new AppError(
"CUSTOMER_NOT_FOUND",
"The requested customer does not exist."
);
}
return customer;
}
Another operation can reuse the same type:
public Result<int> CreateOrder(Order order)
{
if (order.Items.Count == 0)
{
return new AppError(
"EMPTY_ORDER",
"An order must contain at least one item."
);
}
return _orderRepository.Create(order);
}
Both methods follow the same contract, even though their successful return values are different.
The calling code can handle the result using pattern matching:
var result = GetCustomer(101);
string message = result switch
{
Customer customer =>
$"Customer found: {customer.Name}",
AppError error =>
$"Operation failed: {error.Code} - {error.Message}"
};
Console.WriteLine(message);
This pattern is useful when multiple application services need a consistent way to communicate expected failures.
Union Types vs Other Error-Handling Approaches
Union types are not the only way to model success and failure in C#. It is important to understand how they compare with existing approaches.
Approach |
How it works |
Main limitation |
object
|
Returns different types through one common type |
Requires runtime type checking |
null
|
Uses null to indicate failure or missing data |
Does not explain why the operation failed |
Boolean flag |
Uses IsSuccess with separate data properties |
Can expose invalid combinations of state |
Exceptions |
Communicates failures through thrown exceptions |
Expected failures can make control flow harder to follow |
Custom Result class |
Wraps success and failure information |
Requires additional implementation and careful state management |
Union types |
Defines a fixed set of possible outcomes |
Requires callers to handle the declared cases |
Each approach has a place in application development.
For example, exceptions remain appropriate for unexpected infrastructure failures, programming errors, and exceptional conditions. Union types are particularly useful when failure is an expected outcome that the calling code should handle explicitly.
Advantages and Disadvantages of Union Types
Advantages
Clear method contracts: Developers can understand possible return values directly from the method signature.
Better compiler assistance: Exhaustive switch expressions help identify missing cases when a union changes.
Reduced runtime type checking: Callers do not need to inspect arbitrary objects to discover the result type.
Reusable generic types: A generic Result<T> can support multiple application services.
Better maintainability: Separate outcome types make business rules easier to understand and modify.
Disadvantages
Framework requirements: Native union declarations require the appropriate C# language and .NET runtime support.
Additional type definitions: Applications with many possible outcomes may need several small record types.
API evolution: Adding a new union case can require changes to existing switch expressions.
Learning curve: Developers unfamiliar with pattern matching and union concepts may need time to understand the approach.
Not a replacement for exceptions: Unexpected failures and programming errors still require appropriate exception handling.
Best Practices for Using Union Types in Production
Union types are most useful when their cases represent meaningful application outcomes. A few implementation practices can make them easier to maintain.
Use Meaningful Case Names
Avoid generic names such as Success, Error, or Failure when a more descriptive name is available.
For example, CustomerNotFound communicates more information than Failure.
Keep Error Information Structured
Avoid returning only a string when the application needs to distinguish different failure conditions.
A structured error can contain a code, message, and additional context.
public sealed record AppError(
string Code,
string Message
);
This also makes it easier to map application errors to HTTP responses or logging events.
Do Not Replace Every Exception With a Union
Not every failure needs to become a returned value.
Invalid user input, missing records, and business rule violations are often expected outcomes. Unexpected infrastructure failures may still be handled through exceptions, depending on the application's error-handling strategy.
Choose the approach according to the operation's contract.
Keep API Responses Separate From Internal Results
A union type is an application-level modeling technique. It does not automatically define how an API should respond to an HTTP request.
For example, a CustomerNotFound result can be mapped to HTTP 404, while an invalid request can be mapped to HTTP 400.
Keep this mapping in the API layer rather than mixing HTTP-specific concerns into your domain models.
Understand the Internal Storage
One important implementation detail is that C# generates a struct for a native union declaration. Its default implementation stores the contained value through an object reference.
This means value-type cases can be boxed.
Therefore, union types remove the need to expose object as the method's return contract, but they do not mean that object is never used internally.
For performance-sensitive scenarios involving value types, custom union implementations can provide alternative storage strategies.
Common Mistakes and Troubleshooting
Problem |
Possible reason |
Recommended solution |
Union declaration does not compile |
Project targets an unsupported framework or language version |
Verify the .NET SDK and target framework |
Switch expression reports a missing case |
A declared union alternative is not handled |
Add the missing pattern |
Nullability warning appears |
A union case or contained value can be null |
Handle the null case explicitly |
Unexpected failure reaches the caller |
Infrastructure exceptions were not mapped or handled |
Define an appropriate exception-handling boundary |
Adding a new case breaks existing code |
Existing switch expressions are no longer exhaustive |
Update the affected consumers |
One important point is that union types should not be treated as a way to eliminate all runtime failures. They improve how expected outcomes are represented, but database connectivity, timeouts, and unexpected exceptions still require proper operational handling.
Summary
Modeling success and failure is an important part of designing maintainable C# applications. Returning an object, relying on null values, or combining Boolean flags with multiple nullable properties can make application contracts difficult to understand.
Union types provide a more structured alternative by defining the possible outcomes of an operation through a single type. With C# 15, developers can declare native unions and use switch expressions to handle their cases with compiler assistance.
For application developers, the biggest benefit is clarity. A method communicates what it can return, callers can handle each outcome explicitly, and changes to the result contract become easier to identify during development.
Generic result unions also make it possible to apply the same success-and-failure pattern across repositories, services, and business operations without repeatedly creating different response structures.
Ultimately, union types are a useful addition to modern C# development when an operation has multiple well-defined outcomes that deserve explicit handling.
Join the conversation! Your thoughts help the community grow.