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:
API response processing
Message processing
Parsing pipelines
Domain result objects
Event processing
Collection-heavy workflows
High-throughput services
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:
Join the conversation! Your thoughts help the community grow.