π Union Types in C#
Estimated reading time: 4 minutes
-
Available from C# 15 / .NET 11 Preview 2.
-
Proposal: https://github.com/dotnet/csharplang/blob/main/proposals/unions.md
β οΈ Disclaimer β Preview feature
- Requires C# 15 / .NET 11 Preview 2 or later.
- As of .NET 11 Preview 2,
UnionAttributeandIUnionare not yet included in the runtime. You must declare them manually in your project (see Union implementation in the docs).- This is a preview β APIs and behaviors may change before the final release.
π 1. What is a Union Type?
-
A union type in C# is a union of types via a wrapper type.
-
It is a type union β NOT a tagged union or discriminated union in the traditional sense. Quoted directly from the proposal:
"The proposed unions in C# are unions of types and not 'discriminated' or 'tagged'."
The distinction:
- Discriminated union (F#, Haskell): uses a dedicated discriminator/tag field to tell cases apart.
- Type union in C#: uses the runtime type of
Valueitself as the discriminator β no separate tag field. - In terms of behavior, it resembles a discriminated union in that pattern matching can identify the exact case, but the storage mechanism is different.
-
It still expresses the "OR" semantics between types, but its underlying form is a box that holds one of the possible types.
For example, using the Union declaration syntax:
// Union of existing types
public union Pet(Cat, Dog);
Is lowered to:
[Union] public struct Pet : IUnion
{
public Pet(Cat value) => Value = value;
public Pet(Dog value) => Value = value;
public object? Value { get; }
... // original body
}
Clearly:
Petis just a box that holds either aCator aDogPetis called the union typeCatandDogare called case types
The compiler guarantees exhaustiveness when using a union type with pattern matching.
π 2. Union Behaviors
π 2.1. Union Conversions
There is an implicit conversion from each case type to the union type:
Pet pet = dog;
// becomes
Pet pet = new Pet(dog);
π 2.2. Union Matching
π 2.2.1. Applied to the Inner Value
-
When the compile-time type of a value is a union type, pattern matching is implicitly applied to the
Valueinside the union type.In other words, the union is implicitly unwrapped via
.Value, and the pattern is applied to thatValue(except when usingvaror_).
Example:
Pet GetPet() => new Dog(...);
var description = GetPet() switch
{
Dog dog => $"A dog: {dog.name}",
Cat cat => $"A cat: {cat.name}",
// No warning about non-exhaustive switch
};
Is lowered to:
GetPet().Value switch {
...
}
Because the compiler knows Pet is a union type, it knows the switch expression above is already exhaustive.
π 2.2.2. Exception: var / _
The var or _ patterns are exceptions. In these cases, the pattern is applied to the Pet value itself, not its Value.
if (GetPet() is var pet) { ... } // 'pet' is the union value returned from `GetPet`
π 2.2.3. Null Checking
When checking whether a nullable union type variable is null,
the null check applies not only to the union value itself but also to the Value inside it.
(This still follows the Union behavior above β when the compile-time type is a union type.)
Pet? pet = ...;
pet is null
Is lowered to:
Pet? pet = ...;
// If Pet is a class union type
pet == null || pet.Value == null
// If Pet is a struct union type
pet.HasValue == false || pet.GetValueOrDefault().Value == null
π 2.2.4. The Trap
Pet pet = ...;
pet is Pet
This almost always evaluates to false, because it is lowered to:
pet.Value is Pet
While Value is always Dog or Cat π.
π 3. A Common Source of Confusion: Changing the Compile-Time Type Changes the Meaning
If GetPet() returns object, the story changes entirely.
object GetPet() => new Dog(...);
GetPet() is Pet;
-
Here,
GetPet() is Petevaluates totrueifGetPet()returns an instance ofPet.- Union behavior is not applied, because the compile-time type of the input is
object, notPet. - Therefore,
GetPet() is Petistruein the sense of a normal runtime type check.
- Union behavior is not applied, because the compile-time type of the input is
-
The reason is that the current design only activates union matching when the compile-time type of the input is already a union type. Outside of that case, a union is just a regular type / regular value β no implicit
.Valueunwrapping. -
This is explained directly in the proposal discussion: https://github.com/dotnet/csharplang/discussions/10040
π 4. Mental Model
Pet GetPet() -> GetPet() is Pet // union semantics
object GetPet() -> GetPet() is Pet // normal runtime type check
The same syntax is Pet, but simply changing the compile-time type of the left-hand expression changes the meaning entirely.
This is exactly why many people criticize this proposal as "not idempotent" and easy to get confused by. The discussion at https://github.com/dotnet/csharplang/discussions/10040 also makes it clear that union pattern matching only activates when the compile-time type is a union type.
π 5. Summary β Quick Reference Snippet
// β
Union declaration syntax (C# 15 / .NET 11 Preview 2+)
public union Result<T>(T, Exception);
// β
Implicit conversion from case type β union type
Result<int> ok = 42;
Result<int> fail = new Exception("oops");
// β
Pattern matching β exhaustive, no fallback needed
string msg = ok switch
{
int value => $"Success: {value}",
Exception ex => $"Error: {ex.Message}",
// No warning β compiler knows all cases are covered
};
// β
Null check unwraps both layers
// (union declaration β struct β Result<int>? is Nullable<Result<int>>)
Result<int>? maybe = ...;
// lowered: maybe.HasValue == false || maybe.GetValueOrDefault().Value == null
if (maybe is null) { }
// β οΈ Trap: always false due to implicit unwrapping
Result<int> r = ...;
r is Result<int> // β r.Value is Result<int> β false!
// β οΈ Compile-time type determines behavior
Result<int> r2 = ...; r2 is int // union semantics β
object r3 = ...; r3 is Result<int> // normal runtime type check β οΈ