.NET (OK, C#) finally gets union types

Union types are finally coming to .NET 11 and C# 15. This article explores how they work, how to use them, and how they are implemented under the hood.
Unions are one of those features that have been requested for years, and in .NET 11 (or rather, C# 15) they're finally here. In this post I describe what that support looks like, how you can use them, how they're implemented, and how you can implement your own custom types.
This post was written using the features available in .NET 11 preview 4. Many things may change between now and the final release of .NET 11.
What are union types?
Unions are one of those basic data structures which are used all the time in the functional programming world; they're available in F#, TypeScript, Rust…pretty much any functional-first language. There are many different types of union, but at their core they allow having a type that can represent two different things.
Some of the simplest union types are the Option<T>
and Result<TSuccess, TError>
types. There's no "standard" version of these, but it's super common to see custom implementations. Result<>
is one of the easiest to explain as it can be in one of two states:
- Success—in this case the
Result<>
object contains aTSuccess
value representing the "success" result for an operation that succeeded. - Error—in this case the
Result<>
object contains aTError
value representing the "error" for an operation that failed.
You return a Result<>
object from your method, and then the caller has to explicitly handle both cases instead of assuming success.
This pattern is often called the result pattern and it has both pros and cons in C#. I wrote a series about using this pattern, as well as considering whether it's worth it here.
Union types don't have to be the super generic form like this though. They can be used to represent any arbitrary combined set of types.
Union types in C# 15 with the union
keyword
In the previous section I used the classic Result<>
type as an example of a union, but unions are far more versatile than that. They're ideal whenever you want to deal with data that could be one of several potentially unrelated types.
For example, imagine we have three different record
types, containing different properties, representing Operating Systems:
public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);
Note that these types don't have any values in common. Prior to C# 15, the main options for handling something which could be a Windows
or Linux
or MaxOS
object would be:
- Try to create a base class from which all the types derive. That
mightwork, but what if you don't control these types because they come from a library? - Store the type in an
object
instance. This works, but you lose all the safety of working with types in this case. - Use some "tag" value for keeping track of which type your object contains, e.g. using an
enum
to track this.
In C# 15, we get direct support for this scenario with the union
keyword, as shown below:
// 👇 Use `union` as the type
public union SupportedOS(Windows, Linux, MacOS);
// 👆 List the types that are part of the union
You can create an instance of the SupportedOS
type in a couple of ways:
// You can call new and pass in an instance
SupportedOS os = new SupportedOS(new MacOS("Tahoe", 25));
// Or you can use implict conversion (which calls new() behind the scenes)
SupportedOS os = new MacOS("Tahoe", 25);
The generated union
type implements the IUnion
interface:
public interface IUnion
{
object? Value { get; }
}
so you can always get the "inner" case value back out as an object?
if you need to:
// You can access the stored "inner" object using `.Value`
Console.WriteLine(os.Value); // MacOS { Name = Tahoe, Version = 25 }
However, the canonical way to work with unions is to use a switch
expression:
string GetDescription(SupportedOS os) => os switch
{
Windows windows => $"Windows {windows.Version}",
Linux linux => $"{linux.Distro} {linux.Version}",
MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
}; // note: no discard _ required
The switch
expression automatically extracts the inner case type, and a very neat thing is that you don't need to include the _ =>
"discard" case either: the compiler enforces that you check for each of the allowed values, but you only need to check these values. And if you forget one, you'll get a warning:
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.
Note that if one of your case types is nullable, e.g.
MacOS?
then you'll need to handlenull
in yourswitch
expressions too.
To come full circle, we could perhaps implement the Result<>
type as the following (just an example, there's lots of different implementations we could choose!)
public union Result<T>(T, Exception);
or to show another classic, the Option<T>
type:
public record class None;
public union Option<T>(None, T);
That's the basics of the union
types in C# 15, so next we'll look at how you can use them today, before we look behind the scenes at how they're implemented.
Using union
types in .NET 11
To use union
types you need to do two things:
- Install .NET 11 preview 2+ SDK. The initial
union
support was added in preview 2, but you'll have a smoother experience if you install preview 4+. - Enable preview language support in your .csproj files, by adding
<LangVersion>preview</LangVersion>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- 👇 Add this -->
<LangVersion>preview</LangVersion>
<TargetFrameworks>net11.0;net8.0;net48</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Note that although you need to use the .NET 11 SDK, you can target earlier versions of the runtime, such as I'm doing in the above .csproj file. The union
support is implemented as a compiler feature, so it's available on earlier runtimes (even if it's not technically supported on them).
However, if you're targeting earlier runtimes (or you're using .NET 11 preview 2 or preview 3), then you'll also need to add some helper types to your project:
#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; }
}
These were added to .NET 11 in preview 4, so they'll be available automatically if you're using a newer SDK, but you'll need to include them if you're targeting earlier runtimes, regardless.
As you might have guessed, when the compiler creates the union
types, it uses this attribute and implements this interface. In the next section we'll take a look at what the generated code looks like, to understand how the union
types are implemented.
In terms of IDE support, if you're using either Visual Studio Preview, or VS Code's C# DevKit Insiders, then you should have initial support. Support for JetBrains Rider is still pending.
How are union
types implemented
You can see the full spec for union
types here, but the standard generated code is really pretty simple:
using System.Runtime.CompilerServices;
[Union]
public struct SupportedOS : IUnion
{
public object? Value { get; }
// Constructors for each case type
public SupportedOS(Windows value) => this.Value = (object) value;
public SupportedOS(Linux value) => this.Value = (object) value;
public SupportedOS(MacOS value) => this.Value = (object) value;
}
As you can see, the generated SupportedOS
type:
- Is a
struct
, decorated with the[Union]
attribute. - Has a single, readonly,
object? Value
property, implementing theIUnion
interface. - Has a constructor for each of the case types
Source: Hacker News


















