DEV Community

Manu Shukla
Manu Shukla

Posted on • Originally published at ecorpit.com

C# 15 union types in .NET 11: migrating off OneOf without boxing your hot path

#c

C# 15 union types in .NET 11: migrating off OneOf without boxing your hot path

Summary. Union types are one of the two headline C# 15 features in .NET 11, alongside collection expression arguments, and the runtime scaffolding is now shipping: the .NET 11 Preview 6 release on 14 July 2026 lists "Unions ship their support types in the box" under C#, plus System.Text.Json serialisation of union values and union support in ASP.NET Core. Microsoft Learn puts the final .NET 11 release in November 2026, roughly 4 months out. Initial union support landed back in Preview 2, the supporting types followed in Preview 4 as documented on 19 May 2026, and the Learn overview was updated for Preview 5 on 11 June 2026.

Here is the part the announcements skip. The compiler turns a union declaration into a struct with a single object? field. If your case types are int, bool or any other value type, every assignment boxes onto the heap. Moving off OneOf saves you $0 in licence fees, since it is a free NuGet package, so the case for migrating rests entirely on compile-time exhaustiveness and on what the allocation profile does to your hot paths.

This guide covers the syntax, the generated code, the boxing behaviour, the documented non-boxing escape hatch, and a straight answer on whether to adopt this before general availability.

What actually shipped, and in which preview

The sequence matters if you are reading older posts.

Union declarations and union patterns arrived in Preview 2. At that point the runtime did not include the supporting types: the C# reference carries an explicit note that in .NET 11 Preview 2 the runtime ships neither UnionAttribute nor the IUnion interface, and you had to declare both yourself. Andrew Lock, author of ASP.NET Core in Action (Manning) and the .NET Escapades blog, records that the types were added in Preview 4. By the time Microsoft Learn updated its .NET 11 overview for Preview 5, "discriminated-union scaffolding (UnionAttribute and IUnion)" appears in the libraries list. Preview 6 makes it official in the release notes.

Practical effect: on a current SDK you write a union and it compiles with no boilerplate. On anything older, or when multi-targeting an earlier runtime, you still ship the shim yourself.

The syntax, in one line

A union declaration names the type and lists its case types:

public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);

public union SupportedOS(Windows, Linux, MacOS);
Enter fullscreen mode Exit fullscreen mode

Those three records share no base class and no interface. That is the point. Before C# 15 your options were an artificial base class you may not control, an object field that throws away type safety, or an enum tag you have to keep in sync by hand.

You create a value by constructor or by implicit conversion:

SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25));
SupportedOS os2 = new MacOS("Tahoe", 25);   // same thing
Enter fullscreen mode Exit fullscreen mode

And you consume it with a switch expression that needs no discard arm:

string Describe(SupportedOS os) => os switch
{
    Windows w => $"Windows {w.Version}",
    Linux l   => $"{l.Distro} {l.Version}",
    MacOS m   => $"macOS {m.Name} ({m.Version})",
};
Enter fullscreen mode Exit fullscreen mode

Miss a case and the compiler tells you:

warning CS8509: The switch expression does not handle all possible values of its input type
(it is not exhaustive). For example, the pattern 'MacOS' is not covered.
Enter fullscreen mode Exit fullscreen mode

That warning is the entire value proposition. Add a fourth OS to the union and every switch that has not been updated lights up at compile time.

Turning it on

Two steps, per the documented setup:

Install a .NET 11 preview SDK. Preview 2 is the floor for union support; a current preview gives you the in-box types.

Enable preview language features in the project file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <LangVersion>preview</LangVersion>
    <TargetFrameworks>net11.0;net8.0</TargetFrameworks>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

Unions are a compiler feature, so you can use the .NET 11 SDK and still target earlier runtimes. When you do, add the shim:

#if !NET11_0_OR_GREATER
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)]
public sealed class UnionAttribute : Attribute;

public interface IUnion
{
    object? Value { get; }
}
#endif
Enter fullscreen mode Exit fullscreen mode

Tooling is uneven. Visual Studio Preview and the VS Code C# Dev Kit Insiders build have initial support; JetBrains Rider support was still pending as of Andrew Lock's May 2026 write-up. If your team standardises on Rider, check that before you plan a migration sprint.

What the compiler generates, and why it boxes

The union keyword lowers to a struct:

[Union]
public struct SupportedOS : IUnion
{
    public object? Value { get; }

    public SupportedOS(Windows value) => Value = (object)value;
    public SupportedOS(Linux value)   => Value = (object)value;
    public SupportedOS(MacOS value)   => Value = (object)value;
}
Enter fullscreen mode Exit fullscreen mode

Three things follow from that shape.

It is always a struct. The generated form is opinionated: always a struct, always storing contents as object?, with one constructor per case type and a Value property.

There is no implicit conversion operator. The compiler rewrites SupportedOS os = new MacOS(...) into a constructor call. The [Union] attribute is what drives both the conversion and pattern matching. Strip the attribute off a hand-written union and you get error CS0029: Cannot implicitly convert type 'MacOS' to 'SupportedOS' plus error CS8121 on every pattern arm.

Value types box. This is the trap:

public union IntOrBool(int, bool);
Enter fullscreen mode Exit fullscreen mode

generates a constructor that boxes the int and the bool into the object? Value field. Every assignment allocates. As Andrew Lock puts it, "In many cases, the boxing allocation won't really matter" — but in a hot path it does, and a union is meant to be close to free.

Reference-type cases do not have this problem. If all your case types are classes or records, the object? field holds the reference you already had. The boxing question only bites on int, bool, DateTime, Guid, enums and your own structs.

The non-boxing escape hatch

The feature anticipates this. Any class or struct carrying [Union] and following the basic union pattern counts as a union type, and you can implement the pattern by hand.

The basic union pattern requires a [Union] attribute on the type, one or more public constructors each taking a single by-value or in parameter (the parameter type defines a case type), and a public Value property of type object? with a getter.

On top of that, the non-boxing access pattern requires a bool HasValue property returning true when Value is not null, and a bool TryGetValue(out T value) method for each case type. The compiler prefers TryGetValue over the Value property when lowering pattern matches, so value types stay unboxed.

[Union]
public struct IntOrBool : IUnion
{
    private readonly bool _isBool;
    private readonly int _value;

    public IntOrBool(int value)  { _isBool = false; _value = value; }
    public IntOrBool(bool value) { _isBool = true;  _value = value ? 1 : 0; }

    public bool HasValue => true;

    public bool TryGetValue(out int value)
    {
        value = _value;
        return !_isBool;
    }

    public bool TryGetValue(out bool value)
    {
        value = _isBool && _value is 1;
        return _isBool;
    }

    // Required by IUnion. Still boxes, but the compiler will not use it
    // for pattern matching once TryGetValue exists.
    public object Value => _isBool ? _value is 1 : _value;
}
Enter fullscreen mode Exit fullscreen mode

With that in place a switch on IntOrBool lowers to TryGetValue(out int _) and TryGetValue(out bool _) calls rather than a boxed is int test on Value.

The cost is that you write and maintain the tag logic the compiler was supposed to write for you. Do this for the two or three unions that sit in a request path, not for all of them.

Comparing the options

Approach How it closes the set Runtime cost on value-type cases
Abstract base class Inheritance, only if you own every type None, but forces a type hierarchy you may not want
Marker interface Implementation, still open to new types None; no exhaustiveness checking from the compiler
Enum tag plus fields Hand-written discriminant you must keep in sync None; correctness depends entirely on review
OneOf or Sasa package Generic wrapper types from NuGet Depends on the library; no switch exhaustiveness before C# 15
union declaration (C# 15) Compiler-enforced closed set, exhaustive switch Boxes every value-type case into object?
Custom [Union] with TryGetValue Same compiler enforcement No boxing; you maintain the tag logic

The bottom two rows are the real decision. Everything above them is what teams do today.

Pattern matching rules that will surprise you

Three behaviours differ from ordinary pattern matching, and all three are documented.

Patterns apply to Value, not to the union. The union is transparent to matching, which is why Dog d => ... works directly. The corollary catches people out: a pattern like pet is Pet typically does not match, because Pet is tested against the contents of the union rather than the union itself.

var and discard are the exceptions. The var pattern and the _ discard pattern apply to the union value itself, not to Value. In a logical pattern each branch follows its own rule, so var pet and not null captures the union while testing its contents.

Null has two meanings. For struct unions, the null pattern checks whether Value is null, and default produces a Value of null. For class-based unions, null succeeds when either the reference or the Value property is null. If the null state of Value is "maybe null", you must handle null in the switch or take a warning.

Nullability tracking follows the value in: creating a union from a case type gives Value the null state of the incoming value, and the HasValue or TryGetValue members narrow Value to "not null" on the true branch.

What you cannot put in a union

A union declaration can carry a body with extra members, with restrictions. No instance fields, no auto-properties, no field-like events. No public constructors with a single parameter either, because the compiler generates those as the union creation members.

Case types are permissive: classes, structs, interfaces, type parameters, nullable types and other unions all work. Generic unions are fine:

public record class None;
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);

public union Result<T>(T, Exception);
Enter fullscreen mode Exit fullscreen mode

Class-based unions are supported when you need reference semantics or inheritance, written by hand with the [Union] attribute rather than the union keyword.

A union is not a record. It adds no equality, cloning or deconstruction. It answers "which case is it?", not "what fields does it have?"

Should you adopt this now?

Our read, for teams that have to ship on a schedule.

Adopt in new internal code today if you are already on a .NET 11 preview SDK and your team is on Visual Studio or VS Code. Exhaustive switches on a closed set of message or command types pay for themselves the first time someone adds a case.

Wait for general availability before touching public API surface. The feature is behind <LangVersion>preview</LangVersion>, and Andrew Lock's own post carries the standard warning that things may change before the final release. Union member providers, closed enums and closed hierarchies are proposed and may or may not land in .NET 11. Publishing a NuGet package whose signature depends on a preview language feature is a support burden you are choosing.

Do not rip out OneOf on a schedule. The libraries can adopt the new machinery by implementing IUnion and adding [Union], which would give existing types switch exhaustiveness without a rewrite on your side. Waiting costs nothing.

Profile before you hand-write a non-boxing union. Reference-type cases do not box at all. Write the ordinary declaration, measure, and only reach for TryGetValue where an allocation profile says it matters.

The real cost of this migration is not the syntax. It is every switch that quietly relied on a default arm and now has to state what it actually handles.

Where this touches the rest of your stack

Preview 6 extends unions past the language. System.Text.Json now serialises union values, writing the active case directly, and ASP.NET Core gained union support of its own. If you return a union from a minimal API endpoint, the serialisation path is no longer something you have to hand-roll. Preview 6 also brought OpenAPI 3.2 by default and automatic cross-origin request forgery protection in ASP.NET Core, so an upgrade branch is doing more than one thing at once.

Teams running mixed stacks will recognise the pattern from other ecosystems: our notes on Node.js native TypeScript type stripping and the Bun versus Node.js backend runtime decision cover the same tension between compile-time guarantees and runtime cost. For the discipline of staging a runtime upgrade safely, the Postgres 18 migration and tuning guide uses the same approach we would take to a .NET 11 rollout.

India-specific considerations

Two things matter for Indian engineering teams planning this.

Support windows drive the schedule more than the feature does. .NET 11's final release is expected in November 2026, and services teams at Indian IT firms typically plan framework upgrades against the support calendar rather than against language features. Union types are a reason to prepare an upgrade branch now, not a reason to move the date.

Tooling standardisation is the practical blocker. JetBrains Rider is widely used across Indian .NET teams, and Rider support was still pending as of May 2026. If your organisation licenses Rider fleet-wide, verify current support before committing a sprint, because half a team on Visual Studio Preview and half without IDE support is worse than waiting.

For teams modernising older .NET estates, the sequencing questions are the same ones covered in our guide to application modernization from monolith to microservices, and unions are most useful exactly where you are already replacing error-code returns with explicit result types.

FAQ

When does .NET 11 with C# 15 union types actually ship?

Microsoft Learn states that .NET 11 is in preview and the final release is expected in November 2026. Union types were introduced in Preview 2 and their runtime support types shipped in the box with Preview 6, released on 14 July 2026, alongside union serialisation in System.Text.Json.

Why does my union allocate when the case types are integers?

The compiler generates a struct that stores its contents in a single object? property, so any value-type case is boxed onto the heap on assignment. Reference types are unaffected. To avoid the allocation you write a custom union implementing the non-boxing access pattern.

What is the non-boxing access pattern?

A custom union type marked with the [Union] attribute that adds a bool HasValue property and a bool TryGetValue(out T value) method for each case type. The compiler prefers those methods over the Value property when lowering pattern matching, so value-type cases never box.

Do I still need a discard arm in my switch expression?

No. A switch expression over a union is exhaustive when it handles every case type, with no _ or var arm required. Omit a case and the compiler raises warning CS8509. If the union's Value may be null, you must also handle null.

Can I use union types while targeting .NET 8?

Yes. Unions are a compiler feature, so you can build with the .NET 11 SDK and multi-target earlier frameworks. When targeting an earlier runtime you must declare UnionAttribute and IUnion in your own project, since those types only exist in the .NET 11 libraries.

Should we remove the OneOf package from our codebase?

Not on a schedule. OneOf and Sasa are free NuGet packages, so there is no licence saving. Library authors can adopt the new machinery by implementing IUnion and applying [Union], which would give existing types compiler-checked exhaustive switches without any rewrite on your side.

What can a union declaration not contain?

Instance fields, auto-properties and field-like events are all disallowed in a union body. You also cannot declare a public constructor with a single parameter, because the compiler generates those as the union creation members. Case types themselves can be classes, structs, interfaces, type parameters, nullable types or other unions.

Is IDE support ready for a team rollout?

Partly. Visual Studio Preview and the VS Code C# Dev Kit Insiders build had initial support as of May 2026, while JetBrains Rider support was still pending at that point. Verify your team's toolchain before planning migration work, since inconsistent IDE support slows a rollout badly.

How eCorpIT can help

eCorpIT is a senior-led engineering organisation in Gurugram that plans and executes framework upgrades for teams running production .NET services. We stage a preview branch alongside your current build, identify the switch statements and result types where unions remove real defects, and profile allocation before anyone hand-writes a non-boxing implementation. If you are scoping a .NET 11 upgrade ahead of the November 2026 release, talk to us about a migration assessment.

References

  1. .NET 11 Preview 6 is now available!. Source: .NET Blog, .NET Team, 14 July 2026.
  2. Union types (C# reference). Source: Microsoft Learn.
  3. What's new in .NET 11. Source: Microsoft Learn.
  4. .NET (OK, C#) finally gets union types: Exploring the .NET 11 preview, Part 2. Source: Andrew Lock, .NET Escapades, 19 May 2026.
  5. Unions feature specification. Source: Microsoft Learn.
  6. C# release notes for .NET 11 Preview 6. Source: dotnet/core repository.
  7. Library release notes for .NET 11 Preview 6. Source: dotnet/core repository.
  8. ASP.NET Core release notes for .NET 11 Preview 6. Source: dotnet/core repository.
  9. What's new in C# 15. Source: Microsoft Learn.
  10. Pattern matching, C# reference. Source: Microsoft Learn.
  11. C# language versioning. Source: Microsoft Learn.
  12. Closed hierarchies proposal. Source: dotnet/csharplang repository.
  13. Closed enums proposal. Source: dotnet/csharplang repository.
  14. OneOf package. Source: NuGet.

Last updated: 20 July 2026.

Top comments (0)