.NET (OK, C#) finally gets union types: Exploring the .NET 11 preview – Part 2

C# developers have asked for union types for years, and with .NET 11, currently in preview, they have finally arrived through C# 15’s new union keyword. If you have worked with F#, TypeScript, or Rust, you already know the pattern: a type that can hold exactly one of several distinct shapes, checked exhaustively at compile time. This piece walks through what the feature does, how to turn it on in a real project, what the compiler actually generates behind the scenes, and when a custom non-boxing implementation is worth the extra code.

A quick caveat before we get into it. This was tested against .NET 11 preview 4, and the union feature is still a preview-only language addition. Details described here, especially the attribute-driven implicit conversion behaviour, could change before general availability.

What a union type actually solves

A union type lets a single variable represent one of several unrelated types, without forcing them into a common base class or wrapping them in a plain object. The classic examples are Option<T> and Result<TSuccess, TError>. A Result<> type is either a success case holding a value, or an error case holding an exception, and the caller is forced to handle both branches explicitly instead of assuming the happy path.

This is usually called the result pattern in C# circles, and teams have built their own versions of it for years using abstract base classes, tag fields backed by an enum, or third party packages such as OneOf. Union types are a more general idea though. They apply to any set of arbitrary, unrelated types you want to group together, not just success and error pairs.

Declaring a union with the new keyword

Take three record types that model different operating systems. They share no common base class and no common properties.

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

Before C# 15, representing “this could be a Windows, Linux, or MacOS object” meant one of three compromises: invent a shared base class you might not control, store the value as object and lose type safety, or tag the value with an enum and manually keep the tag in sync with the actual type. C# 15 gives a direct answer with the union keyword.

public union SupportedOS(Windows, Linux, MacOS);

That single line declares a union type and lists the cases it accepts. You can construct it explicitly, or rely on an implicit conversion, since the compiler quietly rewrites the assignment into a call to the matching constructor.

SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25));
 
// Or the shorter form, which the compiler rewrites to the line above
SupportedOS os2 = new MacOS("Tahoe", 25);

Every generated union type implements a small IUnion interface with a single object? Value property, so you can always pull the underlying value back out if you genuinely need to. In everyday code though, you will work with union types through a switch expression, not through Value directly.

string GetDescription(SupportedOS os) => os switch
{
    Windows windows => $"Windows {windows.Version}",
    Linux linux => $"{linux.Distro} {linux.Version}",
    MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
}; // no discard case required

Notice there is no discard case, the underscore arrow at the end, that C# developers are used to writing defensively. The compiler already knows the union only accepts three types, so it verifies the switch is exhaustive on its own. Leave out one of the cases and you get a compiler warning instead of a silent runtime surprise later.

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.

One detail worth flagging for teams migrating an existing hand-rolled pattern: if any case type is nullable, such as MacOS?, you still need an explicit null branch in the switch. The exhaustiveness checker does not treat null as automatically covered, and forgetting this is an easy mistake to carry over from other languages that handle it differently.

This mechanism also gives a clean way to write the classic generic wrappers. A minimal Result<T> becomes a union of the value and an exception, and Option<T> becomes a union of the value and a marker type representing absence.

public union Result<T>(T, Exception);
 
public record class None;
public union Option<T>(None, T);

Turning on union types in a real project

Union types are preview-only right now, so two things have to be true before the compiler accepts the union keyword. First, install the .NET 11 preview SDK; preview 2 shipped the initial support, but preview 4 onward is noticeably more stable and worth the upgrade if you are only just starting to experiment. Second, opt in to preview language features in your project file.

<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
 
    <!-- Enables preview C# language features, including union -->
    <LangVersion>preview</LangVersion>
 
    <TargetFrameworks>net11.0;net8.0;net48</TargetFrameworks>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
 
</Project>

A detail that surprised me the first time I tried this: union support is a compiler feature, not a runtime feature. That means the same project can target older TFMs such as net8.0 or even net48, as shown above, as long as you compile it with the .NET 11 SDK. If you are targeting a runtime earlier than .NET 11, or you are still stuck on preview 2 or preview 3, you additionally have to hand-add the UnionAttribute and IUnion interface yourself, since they only shipped in the base class library from preview 4 onward.

#if !NET11_0_OR_GREATER
namespace System.Runtime.CompilerServices;
 
[AttributeUsage(Class | Struct, AllowMultiple = false, Inherited = false)]
public sealed class UnionAttribute : Attribute;
 
public interface IUnion
{
    object? Value { get; }
}
#endif

On the tooling side, IDE support is still catching up, which is expected for a preview language feature. Visual Studio Preview and the VS Code C# DevKit Insiders build both understand union today; JetBrains Rider support has not landed yet at the time of writing. If your team works across a mix of editors, that gap is worth knowing about before you rely on the feature in a shared codebase, since half your team may not get proper IntelliSense or exhaustiveness warnings in the editor.

What the compiler actually generates

It helps to know what union expands into, both to understand the feature properly and because you can build the same shape by hand for your own types. The generated code is a struct decorated with a [Union] attribute, implementing IUnion, with one constructor per case type.

using System.Runtime.CompilerServices;
 
[Union]
public struct SupportedOS : IUnion
{
    public object? Value { get; }
 
    public SupportedOS(Windows value) => this.Value = (object) value;
    public SupportedOS(Linux value) => this.Value = (object) value;
    public SupportedOS(MacOS value) => this.Value = (object) value;
}

Three things stand out here. It is a struct, not a class, so declaring a SupportedOS variable does not by itself allocate on the heap. It has exactly one backing field, Value, typed as object?. And the implicit conversion used earlier, SupportedOS os = new MacOS(…), is not ordinary C# implicit conversion syntax at all. It is the compiler recognising the [Union] attribute and rewriting your assignment into a call to the matching constructor behind the scenes.

You can prove this to yourself by writing the exact same struct by hand and simply leaving off the [Union] attribute. The implicit construction and the switch expression both stop compiling, with the compiler telling you plainly that it cannot convert MacOS to SupportedOS and that none of your switch patterns apply.

error CS0029: Cannot implicitly convert type 'MacOS' to 'SupportedOS'
error CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Windows'.
error CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'Linux'.
error CS8121: An expression of type 'SupportedOS' cannot be handled by a pattern of type 'MacOS'.

Put the attribute back and everything compiles again. This matters in practice because it shows the union keyword is really syntax sugar over a pattern you can implement yourself. That in turn opens the door to custom implementations, including ones that avoid an allocation the compiler-generated version cannot avoid.

Avoiding boxing with a custom TryGetValue implementation

The default generated union always stores its case value in an object? field. For reference types that costs nothing extra, since they already live on the heap. But for a union of value types, such as an int and a bool, every construction boxes the value onto the heap. In a hot path, such as a request pipeline handling thousands of calls a second, that allocation adds up and shows up directly in your GC pause metrics.

public union IntOrBool(int, bool);

The generated struct for that declaration boxes both cases the same way, since it always routes through the shared object? field:

[Union]
public struct IntOrBool : IUnion
{
    public object? Value { get; }
 
    public IntOrBool(int value) => this.Value = (object) value;
    public IntOrBool(bool value) => this.Value = (object) value;
}

To avoid this, the language design allows a non-boxing pattern based on TryGetValue, similar in spirit to the TryParse convention already common across the base class library. Implement bool HasValue and a bool TryGetValue(out T value) overload for each case type, and the compiler routes switch expressions through those methods instead of through the boxing Value property.

[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; // values are never null here
    public bool TryGetValue(out int value)
    {
        value = _value;
        return !_isBool;
    }
    public bool TryGetValue(out bool value)
    {
        value = _isBool && _value is 1;
        return _isBool;
    }
 
    // Still required to satisfy IUnion, but the compiler will not
    // use this path once TryGetValue overloads are available.
    public object Value => _isBool ? _value is 1 : _value;
}

The generated code behind a switch expression changes to match. Instead of unboxing Value and checking its runtime type, it calls TryGetValue directly against the stack-allocated fields, with no heap allocation and the same switch syntax at the call site. The trade-off is that you write more code by hand, and you own the correctness of that hand-written struct instead of trusting the compiler’s default. For most application code the boxing genuinely is not worth worrying about. I would reach for a custom implementation only in code that is already showing up on a profiler, not as a default habit for every union you write.

What is still missing

What has shipped so far in preview is usable, but it is a subset of the full language proposal. A few related pieces are still on the roadmap and may or may not land in the final .NET 11 release.

  • Union member providers, which let you define the case types on a separate type from the union itself, useful when you do not want the case list cluttering the union declaration.
  • Closed enums, which extend the same exhaustive-switch idea to enum values, so you can drop the catch-all discard case for a closed set of enum members.
  • Closed class hierarchies, where a closed modifier stops a class from being derived outside its own assembly, again enabling exhaustive switches without a catch-all.

None of these are required to get value out of union today, but they explain why the feature currently feels slightly narrower than what F# or TypeScript developers are used to. Worth tracking the C# language design discussions if you want to see how these evolve before general availability.

Should you reach for this yet

Union types are still a preview feature tied to a preview language version, so I would not ship them in a production service today, whatever target framework you compile against. Preview 4 has been noticeably more stable than preview 2, but a more stable preview is not the same guarantee as a released language version, and the attribute-driven implicit conversion behaviour could still change before release to manufacturing.

Where I would start experimenting now is internal tooling, prototypes, and libraries you control end to end, particularly anywhere you are already hand-rolling a Result<> or Option<> type with an enum tag or a base class hierarchy. If your codebase already leans on unions heavily, compare this against a mature package such as OneOf before switching. OneOf is stable today, works across every currently supported .NET version, and its switch-style API will feel familiar to anyone coming from the built-in feature. The union keyword wins on ergonomics and removes a dependency once it is finalised, but it genuinely is not there yet.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading