C#  

Benchmarking C# 15 Union Types for API Contracts

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.

DesignClosed SetExhaustive SwitchValue-Type BoxingAPI Clarity
objectNoNoPossibleLow
InterfaceNoNoDependsMedium
Abstract hierarchyUsually noNoDependsHigh
TupleNoNoDependsMedium
Generic resultDependsUsually noDependsHigh
C# unionYesYesYes for generated value-type casesHigh

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:

OperationUnionTraditional DTO
SerializeMeasureMeasure
DeserializeMeasureMeasure
AllocationsMeasureMeasure
Payload sizeMeasureMeasure

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:

ScenarioUnionLegacy HierarchyResult Wrapper
ConstructionMeasureMeasureMeasure
Pattern matchingMeasureMeasureMeasure
SerializationMeasureMeasureMeasure
DeserializationMeasureMeasureMeasure
AllocationMeasureMeasureMeasure
JSON sizeMeasureMeasureMeasure
Compile-time exhaustivenessYesNoNo
Closed setYesNoDepends
Value-type behaviorMeasureMeasureMeasure

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:

  • CPU frequency scaling

  • Background applications

  • Thermal throttling

  • Debug builds

  • Different runtime versions

  • Benchmark warm-up configuration

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.