C# has traditionally used classes, interfaces, inheritance, tuples, and result-wrapper types to represent values that can have different shapes.
That works well for many applications, but a common problem arises: sometimes a value is conceptually one of a fixed set of possible types, and the type system doesn't clearly express that relationship.
C# 15 introduces union types to address this scenario.
A union represents a value that can be one of several defined case types. C# 15 also adds compiler support for exhaustive pattern matching, so the compiler can verify that code handles every declared case.
For ASP.NET Core applications, this can be particularly useful for APIs that return different outcomes, such as a successful response, a validation failure, or a not-found result.
Note: C# 15 union types are part of the current preview language features and require the appropriate .NET 11 preview SDK and language-version configuration. The syntax and implementation may change before the feature is finalized.
What Are Union Types in C# 15?
A union type defines a closed set of possible case types.
For 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 type can contain a Cat, Dog, or Bird.
You can then assign any of those case types to the union:
Pet pet = new Dog("Rex");The important part is that Pet does not represent an arbitrary collection of unrelated objects. It represents a value that must belong to the declared set of cases. (Microsoft Learn)
This makes the relationship explicit in the type system.
Why Union Types Matter for APIs
Consider a typical API endpoint that retrieves a customer.
The operation can have several outcomes:
Customer found
Customer not found
Validation failedA common implementation might use:
public async Task<Customer?> GetCustomerAsync(int id)The problem is that Customer? communicates only that the result can be a customer or null.
It does not explicitly represent why the customer was not returned.
Another approach is a generic result class:
public class Result<T>
{
public T? Value { get; set; }
public string? Error { get; set; }
}This works, but it permits combinations that may not make semantic sense.
For example:
Value = customer
Error = "Customer not found"The type does not necessarily prevent that state.
A union can describe the allowed outcomes directly.
Creating an API Result Union
Let's define three result types:
public record Customer(int Id, string Name);
public record CustomerNotFound(int Id);
public record ValidationError(string Message);Now define the possible result:
public union CustomerResult(
Customer,
CustomerNotFound,
ValidationError);The type communicates the API contract much more clearly:
CustomerResult
├── Customer
├── CustomerNotFound
└── ValidationErrorThere are only three declared cases.
Enabling C# 15
Because union types are currently part of the C# 15 preview feature set, the project needs to enable the preview language version.
In the project file:
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>Microsoft's current documentation identifies C# 15 as a preview language release supported by .NET 11 preview SDKs. (Microsoft Learn)
For development, use an SDK version that supports the C# 15 union implementation you are targeting.
Building a Customer Service
Now the union can be used as the return type of an application service.
public class CustomerService
{
private readonly CustomerDbContext _context;
public CustomerService(CustomerDbContext context)
{
_context = context;
}
public async Task<CustomerResult> GetCustomerAsync(int id)
{
if (id <= 0)
{
return new ValidationError(
"Customer ID must be greater than zero.");
}
var customer = await _context.Customers
.FindAsync(id);
if (customer is null)
{
return new CustomerNotFound(id);
}
return new Customer(
customer.Id,
customer.Name);
}
}Notice that the method returns three different case types:
return new ValidationError(...);or:
return new CustomerNotFound(id);or:
return new Customer(...);The union conversion allows each case type to be assigned to CustomerResult.
Using the Union in an ASP.NET Core Controller
The controller can now pattern-match on the result.
[ApiController]
[Route("api/customers")]
public class CustomerController : ControllerBase
{
private readonly CustomerService _service;
public CustomerController(CustomerService service)
{
_service = service;
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetCustomer(int id)
{
CustomerResult result =
await _service.GetCustomerAsync(id);
return result switch
{
Customer customer =>
Ok(customer),
CustomerNotFound notFound =>
NotFound(new
{
message = $"Customer {notFound.Id} was not found."
}),
ValidationError validation =>
BadRequest(new
{
message = validation.Message
})
};
}
}This is where union types become especially useful.
The switch expression explicitly handles all three cases.
C# 15's union support allows the compiler to understand the declared case types and check switch exhaustiveness.
Why Exhaustive Matching Matters
Without a closed union, developers often write a fallback:
return result switch
{
Customer customer => Ok(customer),
CustomerNotFound notFound => NotFound(notFound),
_ => BadRequest()
};The _ case hides an important question:
What other result types are possible?
With a union, the compiler knows the declared cases.
For:
public union CustomerResult(
Customer,
CustomerNotFound,
ValidationError);the application has a known set of possibilities.
If another case is added later:
public record CustomerLocked(int Id);
public union CustomerResult(
Customer,
CustomerNotFound,
ValidationError,
CustomerLocked);existing switch expressions can identify that the new case needs to be handled.
This becomes valuable as an application grows.
Adding an Authentication Case
Suppose the API eventually needs to distinguish unauthorized access.
Add:
public record CustomerAccessDenied(int Id);Then update the union:
public union CustomerResult(
Customer,
CustomerNotFound,
ValidationError,
CustomerAccessDenied);The controller can explicitly handle the new outcome:
return result switch
{
Customer customer =>
Ok(customer),
CustomerNotFound =>
NotFound(),
ValidationError error =>
BadRequest(error),
CustomerAccessDenied =>
Forbid()
};The API behavior is now tied directly to the domain result types.
Union Types vs Nullable Results
A nullable return type is simple:
public async Task<Customer?> GetCustomerAsync(int id)But it represents only two states:
Customer
nullA union can represent more meaningful outcomes:
Customer
CustomerNotFound
ValidationError
CustomerAccessDeniedApproach | Possible Outcomes | Compiler Knows Cases? |
|---|---|---|
| Customer / null | No |
| Depends on implementation | Usually no |
Exception-based flow | Success / exception | No closed case set |
C# 15 union | Declared case types | Yes |
The union is useful when those different outcomes are part of the normal application flow rather than exceptional failures.
Union Types vs Exceptions
Exceptions remain appropriate for exceptional conditions.
For example:
throw new DatabaseUnavailableException();A database outage is not necessarily a normal result of GetCustomerAsync.
On the other hand:
Customer found
Customer not found
Validation failedare expected outcomes of the operation.
A union can represent these expected outcomes explicitly.
A useful rule is:
Expected business outcome
|
v
Consider a union
Unexpected failure
|
v
Exception / error handlingThis distinction helps keep API contracts understandable.
Handling a Union in the Service Layer
A larger application can keep HTTP-specific behavior outside the service.
For example:
public async Task<CustomerResult> GetCustomerAsync(int id)
{
if (id <= 0)
{
return new ValidationError(
"The customer ID is invalid.");
}
var customer = await _repository.GetByIdAsync(id);
if (customer is null)
{
return new CustomerNotFound(id);
}
return customer;
}The service does not need to know whether the result will eventually become:
HTTP 200
HTTP 400
HTTP 404That decision remains in the API layer.
This separation is useful because the same service could later be consumed by:
A REST API
A background worker
A message handler
A GraphQL endpoint
Another application service
Returning Union Results from Minimal APIs
The same idea can be used with ASP.NET Core Minimal APIs.
For example:
app.MapGet(
"/customers/{id:int}",
async (int id, CustomerService service) =>
{
CustomerResult result =
await service.GetCustomerAsync(id);
return result switch
{
Customer customer =>
Results.Ok(customer),
CustomerNotFound notFound =>
Results.NotFound(notFound),
ValidationError error =>
Results.BadRequest(error)
};
});The application logic remains explicit.
The endpoint translates domain outcomes into HTTP responses.
Testing Union-Based Services
Union types also make unit tests easier to reason about.
For a successful request:
[Fact]
public async Task GetCustomer_ReturnsCustomer()
{
var result =
await service.GetCustomerAsync(10);
Assert.IsType<Customer>(result);
}For a missing customer:
[Fact]
public async Task GetCustomer_ReturnsNotFound()
{
var result =
await service.GetCustomerAsync(999);
var notFound = Assert.IsType<CustomerNotFound>(result);
Assert.Equal(999, notFound.Id);
}For invalid input:
[Fact]
public async Task GetCustomer_InvalidId_ReturnsValidationError()
{
var result =
await service.GetCustomerAsync(0);
var error = Assert.IsType<ValidationError>(result);
Assert.False(string.IsNullOrWhiteSpace(error.Message));
}Each test verifies a specific case.
That is easier to understand than testing a generic result object whose internal state can represent many combinations.
Handling Nullability
Union types also participate in C# nullability analysis.
Microsoft's documentation notes that the compiler tracks the null state of a union's Value, based on the incoming case and the nullability of the case types.
For example, a union can contain nullable cases, but developers should still define API contracts carefully.
Do not assume that using a union automatically eliminates all null-related issues.
For production applications, nullable reference types should remain enabled:
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>The union should represent meaningful application states, while nullability should describe whether an individual value can be absent.
What Happens Internally?
A union declaration such as:
public union Pet(Cat, Dog, Bird);is implemented as a struct following the C# union pattern.
Microsoft documents that generated unions use UnionAttribute and implement IUnion, with a Value property representing the contained value.
Conceptually, the generated structure is similar to:
[Union]
public struct Pet : IUnion
{
public Pet(Cat value) => Value = value;
public Pet(Dog value) => Value = value;
public Pet(Bird value) => Value = value;
public object? Value { get; }
}This is an implementation detail developers should understand when considering performance.
The default generated representation stores the content through an object reference, and value-type cases can therefore involve boxing. Microsoft documents custom union patterns for scenarios where different storage behavior is required.
Performance Considerations
For ordinary API result handling, the clarity of the domain model is likely to be more important than attempting to optimize the union representation prematurely.
However, performance-sensitive applications should understand the implementation.
The default union representation:
Is a struct
Stores the content as
object?Can box value-type cases
Provides generated union conversions
Supports pattern matching
Microsoft also documents custom union types that can avoid boxing for specific scenarios.
If a union is used in a high-frequency, allocation-sensitive path, benchmark the actual workload before choosing between the default and custom implementation.
Common Mistakes
Using a Union for Unrelated Business Concepts
Do not put arbitrary types into one union simply because the compiler allows it.
This:
public union ApplicationResult(
Customer,
Order,
Product,
DatabaseConnection);probably represents several unrelated concepts.
Create smaller, domain-focused unions instead.
Replacing Every Exception with a Union
Not every failure needs to become a union case.
Expected business outcomes and unexpected infrastructure failures should remain conceptually separate.
Making Unions Too Large
A union with dozens of cases can become difficult to maintain.
If a type has too many cases, reconsider the domain boundary.
Ignoring API Contracts
The union is an internal type-system feature.
It does not automatically define the JSON response format your API should expose.
The controller or endpoint still needs to translate cases into appropriate HTTP responses.
Assuming Preview Behavior Is Final
C# 15 union types are currently documented as a preview feature. Syntax and implementation details can change before final release.
Avoid introducing preview features into production systems without understanding the support and upgrade implications.
Best Practices
Keep Cases Domain-Specific
Use meaningful case types:
public union PaymentResult(
PaymentSucceeded,
PaymentRejected,
PaymentValidationError);This is easier to understand than generic object-based results.
Keep HTTP Logic at the API Boundary
Let services return domain results.
Let controllers translate them into HTTP responses.
Use Exhaustive Matching
Avoid unnecessary default branches when the compiler can verify all cases.
Write Tests for Every Case
Every union case should have meaningful automated coverage.
Keep the Union Small
A closed set is useful only when the set is actually manageable.
Benchmark Performance-Sensitive Paths
Do not assume union representation is free of runtime costs, particularly when value types are involved.
Troubleshooting
The union Keyword Is Not Recognized
Make sure the project uses a C# 15-capable SDK and preview language version:
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>Microsoft's current guidance requires a suitable .NET 11 preview SDK for the documented union feature.
Exhaustiveness Errors Appear
Check whether a new case was added to the union without updating existing switch expressions.
This is one of the intended benefits of union types.
A Value-Type Case Behaves Differently
Remember that the default union implementation stores its value through object?, so value-type cases can be boxed.
API Responses Look Inconsistent
The union defines application-level cases; it does not automatically standardize your HTTP response schema.
Make the controller's mapping explicit.
Advantages
Explicit Domain Modeling
A union makes the allowed alternatives visible in the type declaration.
Exhaustive Pattern Matching
The compiler can identify missing cases when switching over a union.
Fewer Ambiguous Result Wrappers
Instead of combining nullable values and error properties, an operation can return one clearly defined case.
Better Refactoring Safety
Adding a new case can surface switch expressions that need updating.
Useful for API and Service Results
Expected outcomes can be represented without relying entirely on exceptions.
Disadvantages
Preview Feature
C# 15 union types are currently documented as a preview feature, so teams need to consider language-version and tooling stability.
Learning Curve
Developers familiar with interfaces and inheritance may initially need to adjust to union-based modeling.
Default Representation Has Trade-Offs
The generated implementation stores the value through object?, which can result in boxing for value-type cases.
Not a Replacement for Every Abstraction
Interfaces, abstract classes, records, exceptions, and result types still have valid use cases.
When Should You Use C# 15 Union Types?
Union types are a good fit when all of the following are true:
A value can represent one of several known alternatives.
The alternatives form a closed set.
Callers need to handle those alternatives differently.
Exhaustive handling provides value.
The domain benefits from making the alternatives explicit.
For example:
public union PaymentResult(
PaymentSucceeded,
PaymentRejected,
PaymentValidationError);is a natural union.
The compiler can help ensure that all payment outcomes are handled.
By contrast, this is usually better represented by an interface:
public interface INotification
{
string Message { get; }
}if external implementations are expected and the set of implementations should remain open.
Union Types and ASP.NET Core Architecture
A clean architecture can use unions at the application boundary without exposing them directly as API contracts.
A typical flow can look like:
HTTP Request
|
v
ASP.NET Core Controller
|
v
Application Service
|
v
CustomerResult Union
|
+---- Customer
|
+---- CustomerNotFound
|
+---- ValidationError
|
v
HTTP ResponseThis gives each layer a clear responsibility.
The service decides what happened.
The API layer decides how that outcome should be represented over HTTP.
That separation can make larger applications easier to maintain.
Summary
C# 15 union types provide a new way to model values that can be one of a fixed set of types.
For ASP.NET Core applications, they can be useful for service results such as:
Success
Not Found
Validation Error
Access DeniedInstead of hiding these possibilities behind nullable values, generic result wrappers, or exception-based control flow, a union makes the possible cases explicit.
The most important feature is not simply the new union keyword. It is the combination of closed case definitions, implicit conversions, pattern matching, and compiler-checked exhaustiveness.
The feature is currently available as part of the C# 15 preview experience, so production adoption should account for its preview status and the possibility of implementation changes.
For developers working on domain-heavy .NET applications, union types provide another tool for expressing business outcomes directly in the type system, while still allowing ASP.NET Core controllers to translate those outcomes into familiar HTTP responses.
Join the conversation! Your thoughts help the community grow.