C# 15 introduces one of the language features developers have requested for years: native union types.
Union types allow a value to represent one of several explicitly declared case types. The compiler knows the complete set of cases and can enforce exhaustive pattern matching. This makes unions particularly interesting for domain models, API responses, validation results, and state machines.
However, a new language feature should not be evaluated only by how concise its syntax looks.
For production applications, developers also need to understand allocation behavior, boxing, pattern matching, API design, serialization, and how unions compare with established approaches such as interfaces, abstract classes, and object.
This article builds a practical benchmark strategy for C# 15 union types and shows how to measure their behavior without assuming that a cleaner API automatically means better runtime performance.
C# 15 union types are currently a preview feature supported by .NET 11 preview releases. Microsoft documents the feature as representing a value that can be one of several case types, with implicit conversions and exhaustive switch expressions.
What Are C# 15 Union Types?
A union defines a closed set of possible types.
For example:
public record class Success(string Message);
public record class Failure(string ErrorCode);
public record class Retry(string Reason);
public union OperationResult(Success, Failure, Retry);
An OperationResult can contain a Success, Failure, or Retry.
The compiler provides implicit conversions from the declared case types:
OperationResult result =
new Success("Operation completed");
The major benefit becomes visible when consuming the value:
string message = result switch
{
Success success => success.Message,
Failure failure => $"Failed: {failure.ErrorCode}",
Retry retry => $"Retry: {retry.Reason}"
};
The compiler knows that these are the complete set of cases.
This is different from a conventional interface hierarchy where another assembly may potentially introduce another implementation.
Why Benchmark Union Types?
The primary reason to benchmark unions is not to prove that they are universally faster.
They solve a type-modeling problem first.
Performance becomes important when unions are used in high-volume code such as:
There is also an important implementation detail.
Microsoft's language reference explains that a compiler-generated union is a struct implementing IUnion, with a Value property of type object?. The generated constructors accept each case type.
That means a benchmark involving value types can behave differently from one involving reference types because value types may be boxed.
Therefore, a useful benchmark should test more than one scenario.
Comparing Three Common Designs
Before measuring performance, establish the alternatives.
| Approach | Type Safety | Exhaustive Matching | Runtime Design | Main Concern |
|---|
| object | Low | No | Very flexible | Unsafe casts |
| Interface hierarchy | High | Not inherently closed | Reference-based | Hierarchy can remain open |
| C# union | High | Yes | Generated struct | Boxing/value storage considerations |
Consider an object implementation:
object GetResult()
{
return new Success("Done");
}
The caller must determine what was returned:
var result = GetResult();
if (result is Success success)
{
Console.WriteLine(success.Message);
}
There is no compile-time guarantee that GetResult() will continue returning only the expected types.
An interface improves the contract:
public interface IOperationResult
{
}
But the interface remains extensible.
A union explicitly closes the set of cases:
public union OperationResult(Success, Failure, Retry);
That difference is primarily architectural, but it also changes what the compiler can optimize and verify.
Setting Up the Benchmark Project
Because union types are a preview feature, use a .NET 11 preview SDK and enable preview language features.
A project file can contain:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet"
Version="0.15.6" />
</ItemGroup>
</Project>
The exact BenchmarkDotNet package version should be selected according to the version available in your environment. The important point is to use a supported version rather than treating the package version above as a mandatory requirement.
Microsoft's union tutorial requires a .NET 11 preview SDK and LangVersion set to preview.
Creating the Test Types
Start with reference-type cases:
public record class Success(string Message);
public record class Failure(string ErrorCode);
public record class Retry(string Reason);
public union OperationResult(Success, Failure, Retry);
Now create equivalent interface-based types:
public interface IOperationResult
{
}
public record class InterfaceSuccess(string Message)
: IOperationResult;
public record class InterfaceFailure(string ErrorCode)
: IOperationResult;
public record class InterfaceRetry(string Reason)
: IOperationResult;
These give us two comparable representations.
Benchmarking Union Construction
The first experiment measures construction.
using BenchmarkDotNet.Attributes;
public class UnionConstructionBenchmark
{
private readonly Success _success =
new("Operation completed");
[Benchmark]
public OperationResult CreateUnion()
{
return _success;
}
[Benchmark]
public IOperationResult CreateInterface()
{
return new InterfaceSuccess(
"Operation completed");
}
[Benchmark]
public object CreateObject()
{
return _success;
}
}
This benchmark should be executed with BenchmarkDotNet rather than using a simple Stopwatch.
A stopwatch around one or two method calls is easily affected by JIT compilation, operating-system scheduling, garbage collection, and other environmental factors.
BenchmarkDotNet provides a more appropriate framework for repeated measurements and reports metrics such as execution time and allocation-related information.
Benchmarking Pattern Matching
Construction is only one part of the problem.
The more interesting question is how the different representations behave when consumed.
For a union:
private static string ProcessUnion(
OperationResult result)
{
return result switch
{
Success success => success.Message,
Failure failure => failure.ErrorCode,
Retry retry => retry.Reason
};
}
For the interface version:
private static string ProcessInterface(
IOperationResult result)
{
return result switch
{
InterfaceSuccess success => success.Message,
InterfaceFailure failure => failure.ErrorCode,
InterfaceRetry retry => retry.Reason,
_ => throw new InvalidOperationException()
};
}
Notice the architectural difference.
The union version does not need a catch-all branch when all cases are handled. The interface version requires a fallback because the compiler cannot assume that the interface has only those three implementations.
This is one of the major advantages of unions, even before considering performance.
The Benchmark Should Measure Allocations
Execution time alone is not enough.
For a high-throughput service, allocations can contribute to garbage-collection pressure.
Add memory diagnostics:
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class ResultBenchmark
{
private OperationResult _union =
new Success("Done");
private IOperationResult _interface =
new InterfaceSuccess("Done");
[Benchmark]
public string ProcessUnion()
{
return _union switch
{
Success success => success.Message,
Failure failure => failure.ErrorCode,
Retry retry => retry.Reason
};
}
[Benchmark]
public string ProcessInterface()
{
return _interface switch
{
InterfaceSuccess success => success.Message,
InterfaceFailure failure => failure.ErrorCode,
InterfaceRetry retry => retry.Reason,
_ => throw new InvalidOperationException()
};
}
}
Run the benchmark using:
dotnet run -c Release
Do not use Debug builds for the final comparison.
Reference Types vs Value Types
This is where the benchmark becomes particularly important.
A union stores its underlying value through object?. Microsoft specifically notes that value types can therefore be boxed in the standard generated representation.
Test a value-type case separately:
public readonly record struct SuccessCode(int Code);
public union NumericResult(SuccessCode, Failure);
Then benchmark:
[Benchmark]
public NumericResult CreateNumericUnion()
{
return new SuccessCode(200);
}
The benchmark should examine allocation behavior rather than assuming that a struct-based union is automatically allocation-free.
This distinction is critical.
A union declaration itself being represented as a struct does not mean every value placed inside it has zero allocation cost.
Benchmarking Serialization
Real applications often cross serialization boundaries.
.NET 11 Preview 6 added System.Text.Json support for serializing C# union types, making serialization another worthwhile benchmark scenario.
For example:
using System.Text.Json;
var result = new OperationResult(
new Success("Completed"));
string json = JsonSerializer.Serialize(result);
Measure:
Serialization time.
Serialized payload size.
Allocations.
Deserialization time.
Behavior for every union case.
A useful benchmark should not measure only the successful case.
Test:
Success
Failure
Retry
This helps identify whether different case types result in materially different serialization behavior.
Designing a Production-Useful Benchmark
A benchmark should represent the workload the application actually performs.
A useful test matrix could look like this:
| Scenario | Union | Interface | Object |
|---|
| Construction | Test | Test | Test |
| Pattern matching | Test | Test | Test |
| Reference-type cases | Test | Test | Test |
| Value-type cases | Test | Test | Test |
| Serialization | Test | Test | Test |
| Deserialization | Test | Test | Test |
| Memory allocation | Test | Test | Test |
| Large collection processing | Test | Test | Test |
This is more useful than reporting a single nanosecond measurement.
Avoiding Misleading Benchmark Results
Several common mistakes can invalidate comparisons.
Benchmarking Debug Builds
Always benchmark the configuration you actually intend to evaluate.
For runtime performance experiments, use Release builds.
Using Tiny Benchmarks as Production Evidence
A benchmark that processes three objects in a loop may demonstrate a micro-level behavior without representing the application.
If your service processes thousands or millions of results, reproduce a realistic workload.
Ignoring Garbage Collection
A lower execution time is not necessarily better if it comes with significantly higher allocation pressure.
Use memory diagnostics and inspect allocation behavior.
Mixing Preview SDK Versions
C# 15 union types are still a preview feature. Microsoft explicitly notes that parts of the feature specification remain under development.
Record the exact SDK, compiler, operating system, CPU architecture, and benchmark configuration used.
For example:
.NET SDK: 11.0.x Preview
C#: 15 preview
Configuration: Release
Runtime: .NET 11
OS: Windows/Linux
Architecture: x64
BenchmarkDotNet: <version>
Without this information, reproducing results becomes difficult.
What Should You Measure?
For a serious comparison, capture at least:
Mean execution time.
Error and standard deviation.
Allocated bytes per operation.
Garbage collections where relevant.
Throughput for realistic workloads.
Behavior for reference and value types.
Serialization performance when applicable.
Do not publish fabricated benchmark numbers.
If your experiment has not actually been executed on the target hardware, present the code and methodology as a reproducible benchmark rather than claiming that one approach is faster.
This distinction is especially important for compiler and runtime features because implementation details can change during preview releases.
When Should You Use Union Types?
Union types are particularly attractive when an API has a deliberately limited set of outcomes.
For example:
public union PaymentResult(
PaymentApproved,
PaymentRejected,
PaymentPending);
The consumer can then handle all known outcomes:
string Describe(PaymentResult result)
{
return result switch
{
PaymentApproved => "Payment approved",
PaymentRejected => "Payment rejected",
PaymentPending => "Payment pending"
};
}
This communicates the domain model directly through the type system.
It is less attractive when the set of implementations is intentionally extensible. In those cases, an interface or abstract base type may remain a better design.
Best Practices for Benchmarking C# Unions
Benchmark realistic workloads.
A microbenchmark should answer a specific runtime question.
Separate design benefits from performance benefits.
Exhaustive matching and stronger contracts are language-design benefits even if runtime performance is identical.
Test reference and value types separately.
Boxing can change the allocation profile.
Measure allocations.
Latency alone does not describe runtime behavior.
Use Release builds.
Debug results are not appropriate for production performance conclusions.
Record the compiler and runtime version.
Preview implementations can change.
Benchmark serialization separately.
Serialization adds its own implementation and allocation costs.
Do not generalize from one machine.
CPU architecture, runtime version, workload, and GC behavior all matter.
Frequently Asked Questions
Are C# union types production-ready?
At the time of writing, C# 15 union types are a preview feature in the .NET 11 preview. Microsoft documents them as a public preview feature and notes that some parts of the specification are still under development.
Teams should therefore evaluate them carefully before adopting them in production systems.
Are union types faster than interfaces?
There is no universal answer.
The appropriate comparison depends on the case types, workload, compiler/runtime version, allocations, and access patterns. Benchmark the actual workload instead of assuming that the generated union representation will always be faster.
Do C# union types eliminate boxing?
No.
The standard generated representation stores its value through object?, and value-type cases can therefore be boxed. Microsoft also documents a custom-union approach for scenarios where avoiding boxing is important.
What is the biggest benefit of union types?
For many applications, the biggest benefit is not raw performance.
It is the ability to express a closed set of valid alternatives directly in the type system and have the compiler enforce exhaustive handling.
Conclusion
C# 15 union types introduce a significant new option for modeling closed sets of alternatives.
Their value should not be judged solely by whether they outperform interfaces or other existing patterns. The more important architectural benefit is that the compiler understands the complete set of cases and can enforce exhaustive pattern matching.
For performance-sensitive applications, however, benchmarking remains essential.
The generated representation uses object?, which makes value-type cases particularly important to test for boxing and allocation behavior. Reference-type cases, serialization, construction, pattern matching, and realistic collection workloads should all be measured separately.
The most reliable approach is therefore:
Define realistic workload
↓
Benchmark union implementation
↓
Benchmark existing implementation
↓
Measure latency + allocations
↓
Repeat with reference/value types
↓
Evaluate serialization
↓
Make the architectural decision
C# 15 union types provide a cleaner way to express closed alternatives. Whether they provide a measurable runtime advantage in a particular application is an empirical question—and that is exactly what a well-designed benchmark should answer.