API contracts often have a deceptively simple problem: a method may legitimately return one of several different outcomes.
For example, an order-processing API might return:
OrderCreated
OrderRejected
PaymentRequired
Traditional C# designs usually model this with a base class, interface, object, tuples, or a generic result wrapper.
C# 15 introduces a different option: union types.
A union represents a value that must be one of a fixed set of case types. The compiler knows the complete set of cases and can enforce exhaustive pattern matching. C# 15 also provides implicit conversions from each case type into the union.
For example:
public record class OrderCreated(int OrderId);
public record class OrderRejected(string Reason);
public record class PaymentRequired(decimal Amount);
public union OrderResult(
OrderCreated,
OrderRejected,
PaymentRequired);
A caller can then handle every possible result:
string Describe(OrderResult result) =>
result switch
{
OrderCreated created =>
$"Order {created.OrderId} created.",
OrderRejected rejected =>
$"Rejected: {rejected.Reason}",
PaymentRequired payment =>
$"Payment required: {payment.Amount}"
};
The interesting question for API designers is not simply whether this syntax is cleaner.
The more important questions are:
What does a union mean for API contracts?
How does it behave during serialization?
What happens when value types are used?
What is the allocation and boxing behavior?
Does pattern matching introduce measurable overhead?
How does a union compare with existing result abstractions?
Those questions require measurement rather than assumptions.
What C# 15 Union Types Actually Provide
A declaration such as:
public union OrderResult(
OrderCreated,
OrderRejected,
PaymentRequired);
creates a closed set of possible cases.
Unlike an interface, another assembly cannot simply implement the union and introduce an unknown case. The compiler knows the complete set and can therefore check exhaustive switch expressions.
The generated union is represented as a struct containing an object? value. Each case gets a generated constructor and implicit conversion. This design is convenient, but it has an important performance implication: value-type cases are boxed when stored in the union's Value property.
That implementation detail is one of the reasons benchmarking is worthwhile.
Define the API Contract First
Consider a traditional API result:
public abstract record OrderResult;
public sealed record OrderCreated(
int OrderId) : OrderResult;
public sealed record OrderRejected(
string Reason) : OrderResult;
public sealed record PaymentRequired(
decimal Amount) : OrderResult;
The service can return:
public OrderResult CreateOrder(OrderRequest request)
{
// Business logic...
}
The problem is that the hierarchy is open.
Another type could derive from OrderResult:
public sealed record UnexpectedResult
: OrderResult;
The compiler cannot assume that the three original cases represent every possible runtime value.
With a union:
public union OrderResult(
OrderCreated,
OrderRejected,
PaymentRequired);
the contract is closed.
This is particularly useful when the domain explicitly defines a finite set of outcomes.
Compare Common Result Designs
Before benchmarking unions, establish meaningful alternatives.
| Design | Closed Set | Exhaustive Switch | Value-Type Boxing | API Clarity |
|---|
| object | No | No | Possible | Low |
| Interface | No | No | Depends | Medium |
| Abstract hierarchy | Usually no | No | Depends | High |
| Tuple | No | No | Depends | Medium |
| Generic result | Depends | Usually no | Depends | High |
| C# union | Yes | Yes | Yes for generated value-type cases | High |
The union's biggest language-level advantage is not necessarily runtime performance.
It is the ability to express a closed set of alternatives directly in the type system.
Create a Benchmark Project
For microbenchmarking, use BenchmarkDotNet.
Create a project:
dotnet new console -n UnionBenchmarks
cd UnionBenchmarks
dotnet add package BenchmarkDotNet
Because C# 15 union types are currently part of the .NET 11 preview ecosystem, configure the project against the appropriate .NET 11 preview SDK and C# preview language version. Microsoft currently documents C# 15 as a preview feature available through .NET 11 preview releases.
A project file can look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Do not compare preview results with production claims without recording the exact SDK, runtime, operating system, processor, and benchmark configuration.
Preview implementations can change.
Benchmark Pattern Matching
Start with the simplest benchmark.
Define the cases:
public record class Created(int Id);
public record class Rejected(string Reason);
public record class Pending(decimal Amount);
public union Result(
Created,
Rejected,
Pending);
Then create two handlers.
The union version:
static string HandleUnion(Result result)
{
return result switch
{
Created created =>
$"Created:{created.Id}",
Rejected rejected =>
$"Rejected:{rejected.Reason}",
Pending pending =>
$"Pending:{pending.Amount}"
};
}
The traditional polymorphic version:
public abstract record LegacyResult;
public sealed record LegacyCreated(
int Id) : LegacyResult;
public sealed record LegacyRejected(
string Reason) : LegacyResult;
public sealed record LegacyPending(
decimal Amount) : LegacyResult;
static string HandleLegacy(LegacyResult result)
{
return result switch
{
LegacyCreated created =>
$"Created:{created.Id}",
LegacyRejected rejected =>
$"Rejected:{rejected.Reason}",
LegacyPending pending =>
$"Pending:{pending.Amount}",
_ => throw new ArgumentOutOfRangeException()
};
}
The benchmark should measure these independently.
[MemoryDiagnoser]
public class UnionBenchmarks
{
private readonly Result _union =
new Created(42);
private readonly LegacyResult _legacy =
new LegacyCreated(42);
[Benchmark]
public string UnionSwitch()
=> HandleUnion(_union);
[Benchmark]
public string LegacySwitch()
=> HandleLegacy(_legacy);
}
The important point is not to assume which implementation wins.
Run the benchmark and publish the actual result.
Measure Allocations Separately
Performance comparisons become misleading if only execution time is measured.
Enable:
[MemoryDiagnoser]
BenchmarkDotNet can then report allocation-related information.
This matters because C# union types use a generated struct whose underlying value is stored through an object?. Microsoft explicitly documents that value-type cases are boxed under the standard generated representation.
For reference-type cases such as:
public record class Created(int Id);
the case itself is already a reference type.
For a value-type case:
public union NumericResult(
int,
double);
the generated union representation stores the value through object?, which means boxing can occur.
This makes a useful benchmark:
Reference-type case
vs
Value-type case
The benchmark should measure both execution time and allocations.
Benchmark Reference-Type Cases
Use classes or records:
public record class Success(string Message);
public record class Failure(string Reason);
public union ReferenceResult(
Success,
Failure);
Then:
private readonly ReferenceResult _success =
new Success("OK");
Measure:
[Benchmark]
public string HandleReferenceUnion()
{
return _success switch
{
Success success => success.Message,
Failure failure => failure.Reason
};
}
This establishes a reference-type baseline.
Benchmark Value-Type Cases
Now use structs:
public readonly record struct SuccessCode(int Value);
public readonly record struct ErrorCode(int Value);
public union NumericResult(
SuccessCode,
ErrorCode);
The union's standard generated representation stores its contents through object?, so value-type cases can be boxed.
Benchmark:
private readonly NumericResult _success =
new SuccessCode(200);
[Benchmark]
public int HandleNumericUnion()
{
return _success switch
{
SuccessCode success => success.Value,
ErrorCode error => error.Value
};
}
Then inspect BenchmarkDotNet's allocation results.
Do not describe boxing as automatically problematic. The correct question is whether the allocation cost matters for the workload.
A business API returning one result per HTTP request may have very different requirements from a high-frequency in-memory parser processing millions of values per second.
Benchmark a Custom Non-Boxing Union
C# 15 also allows custom union implementations.
Microsoft documents a non-boxing access pattern using HasValue and TryGetValue, allowing value-type cases to avoid the standard boxing behavior in relevant scenarios.
This gives you a more interesting benchmark:
Generated Union
vs
Custom Non-Boxing Union
The benchmark should compare:
Execution time
Allocations
Garbage collections
Code complexity
API ergonomics
This is where a technical article can provide more value than a simple "C# 15 union types explained" tutorial.
Benchmark API Serialization
Runtime performance is only one part of an API contract.
An HTTP API must also serialize and deserialize its result.
.NET 11 adds System.Text.Json support for C# union types. The serializer recognizes the union contract and can serialize and deserialize the active case. The platform also provides APIs for customizing how union cases are discovered and named.
For example:
public record class OrderCreated(
int OrderId);
public record class OrderRejected(
string Reason);
public union OrderResult(
OrderCreated,
OrderRejected);
A result can be serialized:
var result =
new OrderCreated(1001);
string json =
JsonSerializer.Serialize<OrderResult>(
result);
The exact JSON representation should be validated using the SDK/runtime version under test rather than assumed from a conceptual type declaration.
This is especially important because an API contract is ultimately consumed as JSON, not as a C# type.
Benchmark Serialization and Deserialization Separately
Use separate benchmarks:
[Benchmark]
public string SerializeUnion()
{
return JsonSerializer.Serialize(
_result);
}
and:
[Benchmark]
public OrderResult? DeserializeUnion()
{
return JsonSerializer.Deserialize<OrderResult>(
_json);
}
Then compare against a conventional DTO hierarchy.
[Benchmark]
public string SerializeLegacy()
{
return JsonSerializer.Serialize(
_legacyResult);
}
This produces four important measurements:
| Operation | Union | Traditional DTO |
|---|
| Serialize | Measure | Measure |
| Deserialize | Measure | Measure |
| Allocations | Measure | Measure |
| Payload size | Measure | Measure |
Do not publish fabricated numbers.
The article's value comes from providing a repeatable experiment that readers can run against their own hardware and runtime.
Validate the JSON Contract
Serialization benchmarks should also verify the resulting JSON.
Suppose an API has:
public sealed record ApiResponse(
string RequestId,
OrderResult Result);
Do not assume that the union's CLR representation maps to the JSON contract you want.
Verify:
Case 1 → JSON shape
Case 2 → JSON shape
Round-trip → Same semantic case
Unknown/invalid input → Controlled failure
A performance optimization that creates an ambiguous wire contract is not a good API design.
Benchmark Payload Size
For HTTP APIs, measure serialized size as well.
For example:
var bytes = Encoding.UTF8.GetByteCount(json);
A useful comparison is:
Union contract
vs
Polymorphic DTO
vs
Discriminator-based DTO
The relevant metric is not merely "union is faster."
Measure:
Serialized bytes
Serialization time
Deserialization time
Allocations
A smaller JSON payload may matter more to a remote API than a small in-process pattern-matching difference.
Test Exhaustiveness as a Maintainability Benchmark
Not every benchmark needs to measure CPU time.
One of the strongest advantages of unions is compile-time exhaustiveness.
Consider:
string Describe(OrderResult result) =>
result switch
{
OrderCreated created =>
$"Created {created.OrderId}",
OrderRejected rejected =>
rejected.Reason,
PaymentRequired payment =>
$"Pay {payment.Amount}"
};
If a new case is added:
public record class FraudReview(
string Reason);
and the union becomes:
public union OrderResult(
OrderCreated,
OrderRejected,
PaymentRequired,
FraudReview);
existing exhaustive switch expressions become incomplete.
The compiler can therefore identify affected handling locations.
This is one of the central reasons unions are useful for API and domain contracts: the type system knows the complete set of alternatives.
A traditional interface hierarchy cannot provide the same closed-world guarantee.
Compare Union With a Result Wrapper
A common alternative is:
public sealed record Result<T>(
T? Value,
string? Error);
This design can be useful, but it permits invalid combinations such as:
Value != null
Error != null
or:
Value == null
Error == null
A union can express mutually exclusive outcomes more directly:
public record class Success(Order Order);
public record class Failure(string Error);
public union CreateOrderResult(
Success,
Failure);
Now the result must be one of those cases.
This is a stronger domain contract.
The trade-off is that the API design becomes more explicitly coupled to the case types.
Benchmark Realistic Workloads
Microbenchmarks are useful, but they should not be the only measurement.
Build a small service:
HTTP Request
|
v
Order Service
|
v
OrderResult
|
v
JSON Serialization
|
v
HTTP Response
Test scenarios such as:
Create successful order
Reject order
Request payment
Measure:
Requests per second
p50 latency
p95 latency
p99 latency
Allocations/request
Response size
CPU usage
This matters because a microbenchmark may detect a small allocation difference that disappears inside a real HTTP request dominated by network and serialization costs.
Conversely, a high-throughput in-memory service may care deeply about that allocation.
The workload determines whether the implementation detail matters.
Avoid Benchmarking the Wrong Thing
A poor benchmark might look like:
[Benchmark]
public void BenchmarkUnion()
{
var result = new Created(1);
_ = result;
}
This does not tell you much about API behavior.
A better benchmark follows the actual path:
Construct
↓
Process
↓
Pattern match
↓
Serialize
↓
Deserialize
↓
Validate
The closer the benchmark is to the real workload, the more useful the result.
Recommended Benchmark Matrix
Use a matrix such as:
| Scenario | Union | Legacy Hierarchy | Result Wrapper |
|---|
| Construction | Measure | Measure | Measure |
| Pattern matching | Measure | Measure | Measure |
| Serialization | Measure | Measure | Measure |
| Deserialization | Measure | Measure | Measure |
| Allocation | Measure | Measure | Measure |
| JSON size | Measure | Measure | Measure |
| Compile-time exhaustiveness | Yes | No | No |
| Closed set | Yes | No | Depends |
| Value-type behavior | Measure | Measure | Measure |
This gives the article both performance and architecture dimensions.
Production API Design Considerations
Prefer Unions for Closed Outcomes
A union is a good candidate when the domain naturally has a fixed set of alternatives.
For example:
PaymentApproved
PaymentDeclined
PaymentPending
is naturally closed.
Avoid Huge Unions
A union containing dozens of unrelated cases can become difficult to understand.
If a type contains too many cases, reconsider the domain boundary.
Keep Cases Domain-Focused
Prefer:
public record class PaymentDeclined(
string Reason);
over generic cases such as:
public record class Result2(
object Value);
The purpose of a union is to make the domain contract clearer.
Treat Wire Contracts Separately
A C# union is a language-level type.
An HTTP API is a wire-level contract.
Do not assume that because a CLR union is elegant, its default JSON representation is automatically the best public API.
Validate the actual serialized contract.
Be Careful With Preview Features
C# 15 union types are currently documented as a preview feature, and the .NET 11 implementation is still evolving. Microsoft notes that some features in the union proposal are not yet implemented.
For production APIs, evaluate the stability requirements of your application before adopting the feature.
Common Mistakes
Treating Union Types as a Performance Feature
The primary value is type safety and exhaustive handling.
Performance should be measured rather than assumed.
Ignoring Boxing
The standard generated union stores its value as object?, so value-type cases can be boxed.
If your workload is allocation-sensitive, benchmark it.
Benchmarking Only CPU Time
Include allocations and garbage-collection behavior.
Ignoring Serialization
A union can be efficient in memory but still require careful API-contract validation.
Comparing Unrealistic Microbenchmarks
A one-line switch benchmark does not represent a complete HTTP API.
Include at least one realistic workload.
Publishing Benchmark Numbers Without Environment Details
A benchmark result without:
CPU
OS
.NET SDK
C# version
BenchmarkDotNet version
Build configuration
Runtime configuration
is difficult to reproduce.
Troubleshooting Benchmark Results
Results Change Between Runs
Check for:
Use Release builds and let BenchmarkDotNet manage process execution where possible.
Allocations Appear Unexpectedly High
First determine whether the union contains value-type cases.
The generated union representation uses object?, so value types can introduce boxing.
Then inspect the benchmark with allocation diagnostics.
Serialization Does Not Match the Expected Contract
Inspect the actual JSON produced by the runtime version being tested.
C# union serialization support is part of the .NET 11 library changes and includes union-specific contract APIs.
Do not infer the wire format solely from the C# declaration.
A New Union Case Breaks Compilation
That can be a feature rather than a problem.
If an exhaustive switch becomes incomplete after adding a case, the compiler is identifying code that needs to be reviewed.
Frequently Asked Questions
What is the main advantage of C# 15 union types?
They represent a closed set of alternatives and allow the compiler to enforce exhaustive pattern matching.
Are union types faster than inheritance?
There is no universal answer.
Their performance depends on the case types, access patterns, allocations, serialization, and workload. Benchmark the actual scenario.
Do C# union types allocate?
The generated union itself is a struct, but its contents are stored as object?. Value-type cases can therefore be boxed. Reference-type cases do not incur boxing merely because they are stored in the union.
Can union types be serialized to JSON?
.NET 11 adds System.Text.Json support for C# union types, including serialization and deserialization and customization APIs.
Should I use C# 15 unions for public APIs?
Evaluate carefully.
C# 15 union types are currently part of the .NET 11 preview ecosystem. For public contracts with long compatibility requirements, validate the language and runtime maturity before committing to the design.
Are unions better than interfaces?
They solve different problems.
Use an interface when the set of implementations is intentionally open.
Use a union when the set of valid cases is intentionally closed and exhaustive handling is valuable.
Conclusion
C# 15 union types introduce a new way to express closed alternatives directly in the language.
For API and domain contracts, this can make an important distinction explicit:
Interface
→ "These types belong to this abstraction."
Union
→ "This value must be exactly one of these cases."
That distinction has practical consequences.
The compiler can enforce exhaustive pattern matching, which can make changes to a domain contract easier to detect. C# union types can also represent unrelated types without requiring them to share a common base class.
But the feature should not be adopted based on syntax alone.
The generated representation stores values through object?, making value-type boxing an important consideration. .NET 11 also introduces union-aware JSON serialization, meaning API designers should evaluate both in-process behavior and wire-level behavior.
A meaningful evaluation should therefore benchmark four things:
Type-system behavior
+
Runtime performance
+
Memory allocations
+
JSON API behavior
The strongest conclusion may not be that unions are universally faster or slower.
Instead, the benchmark may show that their biggest advantage is contract correctness and exhaustive handling, while their runtime characteristics depend on how the union is designed and where it is used.
That is the right way to evaluate C# 15 union types: treat them as a new language-level contract mechanism, measure their runtime behavior with real workloads, and make adoption decisions based on both type-system benefits and production requirements.