Introduction

C# has traditionally offered several ways to represent a value that can have different types. Developers commonly use interfaces, abstract base classes, object, or custom result types for this purpose.

C# 15 introduces union types, which provide a compiler-enforced way to represent a value that must be one of a fixed set of types. A union can contain unrelated types, and the compiler can verify that a switch expression handles every defined case.

Union types are currently available as a preview feature with the .NET 11 preview SDK. Microsoft states that C# 15 is planned to ship with .NET 11, while the preview syntax and implementation can still change before release.

This article explores how C# 15 unions work and shows how to benchmark pattern matching and allocation behavior instead of assuming that a newer language feature is automatically faster.

What Are Union Types in C# 15?

A union represents one value from a fixed collection of types.

For example, an application might represent different kinds of notifications:

public record class EmailNotification(string Address);
public record class SmsNotification(string PhoneNumber);
public record class PushNotification(string DeviceId);

public union Notification(
    EmailNotification,
    SmsNotification,
    PushNotification);

The compiler-generated union can contain an EmailNotification, SmsNotification, or PushNotification.

You can assign any of those case types directly:

Notification notification =
    new EmailNotification("[email protected]");

The important part is that the compiler knows the complete set of possible cases.

That makes pattern matching more precise:

string GetDestination(Notification notification)
{
    return notification switch
    {
        EmailNotification email => email.Address,
        SmsNotification sms => sms.PhoneNumber,
        PushNotification push => push.DeviceId
    };
}

There is no _ default arm because the compiler knows the union contains only the declared cases.

Union Types vs Traditional Approaches

Before C# 15, developers could model similar scenarios in several ways.

ApproachClosed SetExhaustive MatchingUnrelated TypesCompiler Enforcement
objectNoNoYesLow
InterfaceNoNoUsually noMedium
Abstract base classNoNoNoMedium
Custom result typeDependsDependsYesDepends
C# 15 unionYesYesYesHigh

An interface or abstract class can restrict the general shape of a model, but additional implementations can normally be introduced later.

A union is different because its declaration specifies the complete set of case types.

This is particularly useful for APIs where the possible outcomes are intentionally finite, such as success/error results, message types, command types, or state representations.

How C# 15 Unions Are Represented

Understanding the implementation is important when discussing performance.

Microsoft's documentation explains that a compiler-generated union is a struct implementing IUnion. Conceptually, a declaration such as:

public union Pet(Cat, Dog, Bird);

is represented similarly 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; }
}

The actual compiler-generated implementation should be treated as an implementation detail rather than something application code should reproduce manually.

The important performance detail is the object? storage.

For reference-type cases, the union stores the object reference. For value-type cases, storing the value through object can introduce boxing. Microsoft explicitly documents this behavior and also describes a custom non-boxing access pattern for performance-sensitive scenarios.

Setting Up a C# 15 Benchmark

Because union types are currently 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.4" />
  </ItemGroup>

</Project>

Microsoft's union tutorial also requires a .NET 11 preview SDK and LangVersion set to preview.

For reproducible benchmarking, keep the benchmark project separate from the production application. This prevents development configuration and unrelated application work from influencing the measurements.

Benchmarking Pattern Matching

Consider three notification types:

public record class EmailNotification(string Address);
public record class SmsNotification(string PhoneNumber);
public record class PushNotification(string DeviceId);

public union Notification(
    EmailNotification,
    SmsNotification,
    PushNotification);

A traditional implementation could use object:

static string ProcessObject(object value)
{
    return value switch
    {
        EmailNotification email => email.Address,
        SmsNotification sms => sms.PhoneNumber,
        PushNotification push => push.DeviceId,
        _ => throw new ArgumentException("Unsupported notification")
    };
}

The union implementation can be exhaustive:

static string ProcessUnion(Notification notification)
{
    return notification switch
    {
        EmailNotification email => email.Address,
        SmsNotification sms => sms.PhoneNumber,
        PushNotification push => push.DeviceId
    };
}

A BenchmarkDotNet benchmark can compare the two paths:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class NotificationBenchmark
{
    private readonly object _objectValue =
        new EmailNotification("[email protected]");

    private readonly Notification _unionValue =
        new EmailNotification("[email protected]");

    [Benchmark]
    public string ObjectPatternMatching()
    {
        return ProcessObject(_objectValue);
    }

    [Benchmark]
    public string UnionPatternMatching()
    {
        return ProcessUnion(_unionValue);
    }

    private static string ProcessObject(object value)
    {
        return value switch
        {
            EmailNotification email => email.Address,
            SmsNotification sms => sms.PhoneNumber,
            PushNotification push => push.DeviceId,
            _ => throw new ArgumentException()
        };
    }

    private static string ProcessUnion(Notification notification)
    {
        return notification switch
        {
            EmailNotification email => email.Address,
            SmsNotification sms => sms.PhoneNumber,
            PushNotification push => push.DeviceId
        };
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        BenchmarkRunner.Run<NotificationBenchmark>();
    }
}

Run the benchmark in Release mode:

dotnet run -c Release

Do not interpret a single execution as a reliable performance result. BenchmarkDotNet performs repeated measurements and reports statistics that are more useful for comparing implementations.

Benchmarking Allocation Costs

Allocation behavior becomes especially interesting when value types are used as union cases.

For example:

public record struct Success(int Value);
public record struct Failure(int Code);

public union OperationResult(Success, Failure);

The union's object-based storage means value-type cases can require boxing.

A benchmark can explicitly track allocations:

[MemoryDiagnoser]
public class AllocationBenchmark
{
    [Benchmark]
    public OperationResult CreateSuccess()
    {
        return new Success(42);
    }
}

[MemoryDiagnoser] adds memory-related information to the BenchmarkDotNet output.

The important point is not that every union allocation is expensive. The actual behavior depends on the case types and access pattern.

Reference-type cases and value-type cases should therefore be tested separately.

What to Measure

A useful benchmark should measure more than execution time.

Consider collecting:

MetricWhy It Matters
MeanAverage execution time
ErrorMeasurement uncertainty
StdDevVariation between measurements
AllocatedManaged memory allocated per operation
Gen0Garbage collections caused by the benchmark
ThroughputUseful for high-frequency operations

For a realistic test, benchmark multiple scenarios:

  1. Reference-type union cases.

  2. Value-type union cases.

  3. Repeated pattern matching.

  4. Creation of union values.

  5. Custom non-boxing union implementations.

  6. Traditional interface or result-type implementations.

This makes the benchmark useful for architectural decisions instead of simply comparing two syntax styles.

Production Considerations

Union types can improve API design when the set of valid outcomes is intentionally closed.

For example:

public record class PaymentSucceeded(decimal Amount);

public record class PaymentFailed(string Reason);

public union PaymentResult(
    PaymentSucceeded,
    PaymentFailed);

A caller can then handle both outcomes explicitly:

string FormatResult(PaymentResult result)
{
    return result switch
    {
        PaymentSucceeded success =>
            $"Payment completed: {success.Amount}",

        PaymentFailed failure =>
            $"Payment failed: {failure.Reason}"
    };
}

If another case is later added to the union, existing exhaustive switches can identify code that needs attention.

This is one of the biggest advantages of unions: the compiler becomes part of the maintenance workflow.

However, union types should not automatically replace every interface or inheritance hierarchy.

If an application deliberately supports third-party implementations or plugins, an open abstraction may be more appropriate.

Best Practices

Use Unions for Intentionally Closed Contracts

Use a union when the set of possible types is known and should remain closed.

Good examples include:

Benchmark Value-Type Cases Separately

Do not assume reference-type and value-type union cases behave the same way.

Value types can be boxed because the generated union stores its value through object-based storage.

Keep Benchmarks Isolated

Use a dedicated benchmark project and run it in Release configuration.

Avoid measuring code together with logging, console output, database calls, network requests, or unrelated application startup work.

Test Realistic Access Patterns

A benchmark that creates one object and immediately exits may not represent a real service.

If the union is used millions of times in a parsing, messaging, or processing loop, benchmark that usage pattern.

Treat Preview Features Carefully

C# 15 union types are currently a preview feature. Microsoft notes that the syntax and supporting implementation can change before the feature ships.

Avoid introducing preview language features into production systems solely because an early benchmark looks favorable.

Common Mistakes

Assuming New Syntax Means Better Performance

Union types primarily solve a type-system and correctness problem.

They should not be selected solely because the syntax looks cleaner.

Ignoring Boxing

A union containing value types can have different allocation characteristics from one containing reference types.

Always use a memory diagnoser when allocation behavior matters.

Using a Default Case Everywhere

One of the benefits of a union is exhaustive matching.

Adding _ => ... everywhere can hide the compiler's ability to identify an unhandled case.

Benchmarking Debug Builds

Debug builds are not appropriate for performance comparisons.

Run benchmarks using Release configuration and keep the environment consistent between runs.

Advantages and Disadvantages

Advantages

Disadvantages

Conclusion

C# 15 union types introduce a significant type-system capability to C#. Instead of representing a finite set of alternatives through object, open interfaces, or custom abstractions, developers can explicitly declare the allowed case types and let the compiler enforce exhaustive pattern matching.

From a performance perspective, the most important lesson is to measure rather than assume. The compiler-generated union uses object-based storage, which can mean different allocation behavior for reference and value types. Microsoft also documents custom non-boxing patterns for scenarios where those costs matter.

For developers evaluating C# 15, a good approach is to benchmark representative workloads, inspect both execution time and allocations, and compare unions against the abstraction they would actually replace.

Union types are therefore best viewed not simply as a new syntax feature, but as a way to make closed API contracts more explicit while giving the compiler more information about the states an application must handle.