If you have ever written a service method that returns either a result or an error, you know the dance. You create a Result<T> class with a boolean flag, or you reach for the OneOf NuGet package, or you build an abstract base class hierarchy and hope nobody forgets to handle one of the cases. The compiler shrugs at all of these — it cannot tell you that your switch is missing a branch because it has no concept of a closed set of types.
C# 15, shipping with .NET 11 later this year, changes that. The new union keyword lets you declare that a value is exactly one of a fixed set of types, and the compiler enforces exhaustiveness at every switch expression. No marker interfaces, no source generators, no third-party packages. Just a one-line declaration and the guarantees you have been asking for since C# 7 introduced pattern matching.
Union types first appeared in .NET 11 Preview 2 (March 2026) and are available now in Visual Studio 2026 Insiders and the .NET 11 preview SDK. The syntax is still evolving — union member providers are not yet implemented — but the core feature is usable today.
Declaring a union
A union declaration is a single line that names the type and lists its cases:
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
That is the entire declaration. No body, no fields, no inheritance. The compiler generates a struct behind the scenes with a constructor and an implicit conversion for each case type, a Value property of type object?, and the metadata needed for exhaustive pattern matching.
Case types can be anything that converts to object: classes, structs, interfaces, type parameters, nullable types, and even other unions. You can use primitive types directly:
public union IntOrString(int, string);
And generics work exactly as you would expect:
public record class None;
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);
Implicit conversions and pattern matching
You do not call a constructor to create a union value. Implicit conversions handle it:
Pet pet = new Dog("Rex");
Console.WriteLine(pet.Value); // Dog { Name = Rex }
When you pattern match on a union, the compiler automatically unwraps the Value property. You write patterns against the case types directly, not against the union struct:
public static string Describe(Pet pet) => pet switch
{
Dog d => $"Dog: {d.Name}",
Cat c => $"Cat: {c.Name}",
Bird b => $"Bird: {b.Name}",
};
No discard pattern. No default branch. The compiler knows that Pet can only ever be a Dog, Cat, or Bird, so those three arms are exhaustive. If you later add Fish to the union declaration, every switch expression over Pet that does not handle Fish produces a compiler warning.
This is the feature that makes unions transformative. With the existing OneOf<T0, T1, T2> pattern, the compiler cannot enforce exhaustiveness — you are on your own. With union, missing a case is a compile-time error.
Real-world example: service results
The classic use case is method return types that model success and failure without exceptions:
public record class Order(int Id, decimal Total, DateTime CreatedAt);
public record class NotFound;
public record class Unauthorised(string Reason);
public union GetOrderResult(Order, NotFound, Unauthorised);
The API layer then handles each outcome explicitly:
public static IResult HandleGetOrder(GetOrderResult result) => result switch
{
Order order => Results.Ok(order),
NotFound => Results.NotFound(),
Unauthorised u => Results.Forbid(),
};
Add a new failure case — say, RateLimited — and the compiler flags every call site that does not handle it. No runtime InvalidOperationException six months down the line when someone adds a case type and forgets to update the API layer.
How unions lower to IL
Under the hood, a union declaration compiles down to a struct marked with [System.Runtime.CompilerServices.Union] that implements the IUnion interface:
[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 UnionAttribute and IUnion interface are intentionally minimal:
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class UnionAttribute : Attribute;
public interface IUnion
{
object? Value { get; }
}
}
This design has a deliberate trade-off: all case values are stored as object?, which means value types get boxed. The compiler optimises pattern matching where possible, but if you are storing millions of int values inside a union, the boxing overhead is real.
// NOTE
In .NET 11 Preview 2, UnionAttribute and IUnion are not yet included in the runtime. You need to declare them yourself in your project. They will ship in a later preview.
Nullability and default values
Unions interact with C#'s nullable reference type analysis. When you create a union from a case value, the Value property inherits the null state of the incoming value. A default struct union has a null Value:
Pet pet = default;
Console.WriteLine(pet.Value is null); // True
var description = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
null => "no pet",
};
When the compiler determines that Value might be null — either because you used default or because the union was assigned from a nullable reference — it requires you to handle null for the switch to be exhaustive. If you only ever construct unions from non-null case values, the null arm is unnecessary.
For nullable union structs (Pet?), the null pattern matches when either the Nullable<Pet> wrapper has no value or the underlying union's Value is null:
Pet? maybePet = new Dog("Buddy");
Pet? noPet = null;
string Describe(Pet? pet) => pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
null => "no pet",
};
Unions with a body
A union declaration can include a body for helper methods, as long as you avoid instance fields, auto-properties, and field-like events:
public union OneOrMore<T>(T, IEnumerable<T>)
{
public IEnumerable<T> AsEnumerable() => Value switch
{
T single => [single],
IEnumerable<T> multiple => multiple,
_ => []
};
}
You cannot declare public constructors with a single parameter in the body — the compiler reserves those as union creation members.
Custom union types
The union keyword is syntactic sugar for the underlying pattern. Any class or struct marked with [Union] is treated as a union type, provided it follows the basic union pattern: at least one public single-parameter constructor, and a public Value property of type object?.
This means you can build a union with a completely different storage strategy. For instance, if your case types are all value types and you want to avoid boxing, you can implement the non-boxing access pattern with HasValue and TryGetValue methods:
[System.Runtime.CompilerServices.Union]
public struct IntOrBool : System.Runtime.CompilerServices.IUnion
{
private readonly int _intValue;
private readonly bool _boolValue;
private readonly byte _tag;
public IntOrBool(int? value)
{
if (value.HasValue) { _intValue = value.Value; _tag = 1; }
}
public IntOrBool(bool? value)
{
if (value.HasValue) { _boolValue = value.Value; _tag = 2; }
}
public object? Value => _tag switch
{
1 => _intValue,
2 => _boolValue,
_ => null
};
public bool HasValue => _tag != 0;
public bool TryGetValue(out int value) { value = _intValue; return _tag == 1; }
public bool TryGetValue(out bool value) { value = _boolValue; return _tag == 2; }
}
When the compiler sees TryGetValue overloads, it uses them instead of the Value property during pattern matching, avoiding the box entirely.
You can also create class-based unions when you need reference semantics:
[System.Runtime.CompilerServices.Union]
public class Result<T> : System.Runtime.CompilerServices.IUnion
{
private readonly object? _value;
public Result(T? value) { _value = value; }
public Result(Exception? value) { _value = value; }
public object? Value => _value;
}
How unions differ from discriminated unions in F#
If you are coming from F#, the terminology can be confusing. C# unions are type unions — they compose existing types into a closed set. F# discriminated unions are tagged unions — each case carries its own named fields.
In C#, you achieve the same effect by declaring record types for each case:
// F# style: type Shape = Circle of radius: float | Rectangle of width: float * height: float
// C# equivalent:
public record class Circle(double Radius);
public record class Rectangle(double Width, double Height);
public union Shape(Circle, Rectangle);
The difference is structural. In F#, Circle is not a standalone type — it is a constructor of Shape. In C#, Circle is a regular record class that happens to be a case of the Shape union. The same Circle type could participate in multiple unions.
Common pitfalls
Boxing value types. The default union declaration stores everything as object?. If your cases include int, double, or other value types, each union creation boxes the value. For hot paths, either use the non-boxing access pattern with a custom union type or stick with dedicated structs.
Forgetting to declare runtime types in Preview 2. Until a later .NET 11 preview ships UnionAttribute and IUnion in the runtime, you need to add them to your project yourself. If you see errors about missing types, this is why.
Treating the var pattern as unwrapping. Most patterns unwrap the union's Value automatically, but var and _ capture the union itself, not its contents. This matters when chaining logical patterns:
// Bad — 'pet' is the union struct, not the underlying Cat/Dog/Bird
if (pet is var p and not null) { /* p is Pet, not the case type */ }
// Good — match the case type directly
if (pet is Dog d) { /* d is the Dog value */ }
Using is Pet on a union value. Because patterns match against Value, testing pet is Pet checks whether the contents are a Pet, which they never are — the contents are a Cat, Dog, or Bird. This is unintuitive at first but consistent with the unwrapping rule.
Assuming unions replace all error handling. Unions model a closed set of known outcomes. They do not replace exceptions for truly exceptional situations like network failures or out-of-memory conditions. Use unions for expected control flow variants, not as a universal exception replacement.
What is coming next
The current preview implements the core feature set: declarations, conversions, pattern matching, exhaustiveness, and nullability. The full proposal also describes union member providers — an interface-based mechanism that lets you define union creation members and access patterns on a separate type. This is not yet implemented but is planned for a future .NET 11 preview.
The broader C# 15 effort also includes closed hierarchies as a related feature. Where unions compose existing types into a closed set, closed hierarchies let you seal an inheritance chain so that the compiler knows the complete set of derived types. Together, they give C# two complementary approaches to exhaustive matching: unions for ad-hoc composition, closed hierarchies for inheritance-based designs.
Summary
- The
unionkeyword declares a value type that holds exactly one of a fixed set of case types - Implicit conversions make union creation seamless — no constructor calls needed
switchexpressions over unions are exhaustive: miss a case, get a warning- Unions lower to structs with a
[Union]attribute and anIUnioninterface - Value types are boxed by default; use the non-boxing access pattern for performance-sensitive scenarios
- Custom unions let you control storage, use class semantics, or implement domain-specific behaviour
- Available now in .NET 11 Preview 2 (with manual
UnionAttribute/IUniondeclarations) - Union member providers and closed hierarchies are coming in later previews