Last updated: October 22, 2026 · Tested with .NET 11 RC2 (SDK 11.0.100-rc.2.26504.105, C# 15) and the .NET 10.0.12 runtime
Short answer: C# 15 adds union types. You declare one with the union keyword and a list of case types. Any value of a case type converts to the union implicitly, and a switch expression over the union is checked for exhaustiveness: no _ => arm needed, and you get a warning if you forget a case.
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
// 1. Basics: implicit conversion and an exhaustive switch
Pet pet = new Dog("Rex");
Console.WriteLine($"1. {Describe(pet)}");
static string Describe(Pet pet) => pet switch
{
Dog d => $"dog {d.Name}",
Cat c => $"cat {c.Name}",
Bird b => $"bird {b.Name}",
};
That's the feature most C# developers have asked for under the name "discriminated unions". I built and ran every example below on the RC2 SDK and recorded the real compiler messages. A few things surprised me: a union is a struct that boxes, default slips straight past the "exhaustive" switch without any warning, System.Text.Json can't read most unions back without extra setup, and on .NET 10 the compiler errors don't tell you what's wrong.
Set Up: .NET 11 SDK and C# 15
.NET 11 is still a release candidate (RC2, supported for production use); the final version ships November 10, and I'll retest these examples on it.
C# 15 is the default language version for projects that target net11.0, so you don't need <LangVersion>preview</LangVersion>. Install the .NET 11 SDK (RC2 today, the final release from November 10) from dotnet.microsoft.com, or use a current Visual Studio 2026 Insiders build. The project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
I used the official RC2 zip, checked its SHA-512 against Microsoft's release metadata, and ran it from a folder, so my installed .NET 10 SDK stayed untouched.
Declaring a Union and Matching on It
A union lists its case types in parentheses. The case types already exist; the union doesn't declare new ones. Records are a natural fit, but any type works, including int and string:
public union Pet(Cat, Dog, Bird);
public union Result(int, string);
Each case type converts implicitly, so Pet pet = new Dog("Rex"); and Result ok = 42; just work. You read the value back with type patterns in switch and is:
Result ok = 42;
Result err = "boom";
Console.WriteLine($"3. {Show(ok)} / {Show(err)}");
static string Show(Result r) => r switch
{
int n => $"ok {n}",
string s => $"error {s}",
};
1. dog Rex
3. ok 42 / error boom
5. pet is Dog: True, pet is Cat: False
A union can also have members. Declare it partial and add the rest in a second declaration:
public partial union Pet
{
public string Name => this switch
{
Cat c => c.Name,
Dog d => d.Name,
Bird b => b.Name,
};
}
With public partial union Pet(Cat, Dog, Bird); elsewhere, pet.Name printed Tom for a cat.
The Compiler Errors and Warnings You'll See
These are the exact messages from the RC2 compiler. Forget a case and you get the familiar CS8509, now naming the missing case type. It is a warning, so add <WarningsAsErrors>CS8509</WarningsAsErrors> if you want a missing case to break the build:
Pet pet = new Dog("Rex");
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
};
Program.cs(2,19): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bird' is not covered.
run: Rex
Assign a type that isn't a case, or test for one, and you get hard errors:
Program.cs(1,11): error CS0029: Cannot implicitly convert type 'Fish' to 'Pet'
Program.cs(2,12): error CS8121: An expression of type 'Pet' cannot be handled by a pattern of type 'Fish'.
One trap: switch on pet.Value instead of pet and you lose exhaustiveness checking. Value is typed object, so the compiler asks for a discard arm even though all three cases are covered:
Program.cs(2,25): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.
run: bird
What the Compiler Generates: a Struct That Boxes
I inspected Pet with reflection. The union is a sealed struct that implements System.Runtime.CompilerServices.IUnion, has one constructor per case type, and stores the case in a single object Value property:
2. Pet is a struct, sealed=True
interfaces: System.Runtime.CompilerServices.IUnion
attributes: System.Runtime.CompilerServices.NullableContextAttribute, System.Runtime.CompilerServices.NullableAttribute, System.Runtime.CompilerServices.UnionAttribute
Method System.Object get_Value()
Constructor Void .ctor(Cat)
Constructor Void .ctor(Dog)
Constructor Void .ctor(Bird)
Property System.Object Value
Because the storage is object, a value-type case is boxed. I created 1,000 Result values from ints and measured with GC.GetAllocatedBytesForCurrentThread():
long before = GC.GetAllocatedBytesForCurrentThread();
int sum = 0;
for (int i = 0; i < 1000; i++)
{
Result r = i;
if (r is int n) sum += n;
}
long after = GC.GetAllocatedBytesForCurrentThread();
1000 Result values from int: 24000 bytes allocated (sum 499500)
That's 24 bytes per value, the size of a boxed int on x64. For reference-type cases (records, classes) there's no extra allocation, since the object already lives on the heap. In a hot path with int, double or struct cases, the union allocates where a hand-written struct wouldn't. Keep using your own struct (or a generic Result<T>) there.
Trap: default and null Bypass the Exhaustive Switch
Because a union is a struct, default(Pet) is a valid value with Value == null. The switch from the top of this post compiled with zero warnings, but at run time:
4. default(Pet) -> SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Pet.
The same happens with an uninitialized field, an array of unions (new Pet[10]) or a default parameter. Converting a null reference does produce warnings, and it fails the same way:
Pet pet = (Dog?)null;
Console.WriteLine(pet switch { Dog d => d.Name, Cat c => c.Name, Bird b => b.Name });
Program.cs(1,11): warning CS8604: Possible null reference argument for parameter 'value' in 'Pet.Pet(Dog value)'.
Program.cs(2,23): warning CS8655: The switch expression does not handle some null inputs (it is not exhaustive). For example, the pattern 'null' is not covered.
run: Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
run: Unmatched value was Pet.
If a union can be default in your code, add a null arm. This compiled with no warnings and printed (no pet) and then True:
Pet pet = default;
string name = pet switch
{
null => "(no pet)",
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
Console.WriteLine(name);
Console.WriteLine(pet.Value is null);
Exhaustive means "covers every case type", not "can't throw".
Unions and System.Text.Json
System.Text.Json in .NET 11 understands unions, with one catch. A union serializes as its current value only, with no type marker. Reading it back works when each case has a different JSON kind, like a number and a string:
Result: 42 / "boom"
back: int 42 / string boom
When two or more cases are JSON objects (the usual situation with records), deserializing fails:
Shape: {"Radius":2.5}
JsonException: JSON value type 'Object' is ambiguous for union type 'Shape' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
The fix is a type classifier. .NET 11 ships one that picks the case by the JSON property names, JsonUnionTypeStructuralClassifier. You attach it with [JsonUnion]:
public record Circle(double Radius);
public record Square(double Side);
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union StructuralShape(Circle, Square);
StructuralShape: {"Side":3}
back: circle area 19.63
back: square area 9
JsonException: The custom type classifier for union type 'StructuralShape' returned null for JSON token type 'StartObject'. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
{"Radius":2.5} became a Circle, {"Side":3} a Square, and an unknown shape ({"Width":3}) failed with a clear error. If two cases have the same property names (for example Cat(string Name) and Dog(string Name)), no structural rule can tell them apart. Then you need your own classifier: derive from JsonTypeClassifierFactory, whose JsonTypeClassifier delegate reads ahead with a Utf8JsonReader and returns the case Type. Or switch to a closed class hierarchy with [JsonPolymorphic] and a $type discriminator, which writes the type into the JSON.
Union Types vs Closed Hierarchies
C# 15 also adds the closed modifier, and the two features look similar in a switch. I compiled a closed record in a class library and used it from an app:
namespace Lib;
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
using Lib;
GateState state = new Open(40);
Console.WriteLine(Describe(state));
Console.WriteLine(Partial(state));
static string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
};
static string Partial(GateState state) => state switch
{
Open(var percent) => $"{percent}% open",
};
Deriving from it in the app project fails, and the switch that skips Closed gets CS8509 like a union does:
## with Jammed : GateState in App
App/Program.cs(18,21): error CS9382: 'Jammed': cannot use a closed type 'GateState' from another assembly as a base type.
## without it
App/Program.cs(13,49): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Lib.Closed' is not covered.
40% open
40% open
How I'd choose:
- Union when the cases are types that already exist and don't share a base:
intorstring, a domain type or an error type, types from different libraries. The union is a struct, and value-type cases box. - Closed hierarchy when you own all the types and want them to share members and a base type. It's an ordinary class hierarchy: no boxing,
nullis the only "missing" value, and System.Text.Json's existing[JsonPolymorphic]support writes a type discriminator.
Can You Use Union Types on .NET 10?
Not out of the box. Unions need the C# 15 compiler and two runtime types that first ship in .NET 11. Targeting net10.0 with the .NET 11 SDK gives:
Program.cs(6,14): error CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined or imported
Program.cs(6,14): error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.UnionAttribute..ctor'
With the default language version (C# 14), the compiler doesn't even recognize union as a keyword, and the errors point everywhere except at the cause:
Program.cs(6,1): error CS0106: The modifier 'public' is not valid for this item
Program.cs(6,1): error CS8803: Top-level statements must precede namespace and type declarations.
Program.cs(6,21): error CS1001: Identifier expected
Program.cs(6,26): error CS1001: Identifier expected
If you get CS0106, CS8803 and CS1001 on a union line, your project isn't compiling as C# 15. Check TargetFramework and LangVersion.
Declaring the two missing types yourself makes it compile. The shapes below match what I read from the RC2 runtime with reflection:
// Polyfill so the C# 15 compiler can emit unions on net10.0 (the types ship in the .NET 11 runtime)
namespace System.Runtime.CompilerServices
{
public interface IUnion
{
object? Value { get; }
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class UnionAttribute : Attribute
{
}
}
(build succeeded, 0 errors, 0 warnings)
The Pet switch then ran on the installed .NET 10.0.12 runtime and printed Rex. I wouldn't ship it. Preview language features on an older target framework aren't supported, the System.Text.Json union support isn't in .NET 10, and the shapes could still change before the final release. Use it to try the feature, then move to net11.0.
Quick Reference: Union Errors and Fixes
| Message | Cause | Fix |
|---|---|---|
CS8509 ... the pattern 'Bird' is not covered | A case type is missing from the switch | Add the arm; make CS8509 an error |
CS8509 ... the pattern '_' is not covered | Switching on .Value (an object) | Switch on the union itself |
CS0029: Cannot implicitly convert type 'Fish' to 'Pet' | Not a case type | Add it to the union's case list |
CS8121 ... cannot be handled by a pattern of type 'Fish' | is or case with a non-case type | Remove the check, or add the case |
SwitchExpressionException ... Unmatched value was Pet | default or null union, no warning | null => arm or check Value is null |
JSON value type 'Object' is ambiguous for union type | Several object-shaped cases | [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] or a custom classifier |
CS0518: Predefined type '...IUnion' is not defined | Target is older than net11.0 | Target net11.0 |
CS0106, CS8803, CS1001 on a union line | Language version below C# 15 | Target net11.0 (C# 15 is the default there) |
CS9382 ... cannot use a closed type ... from another assembly | Deriving from a closed class | Derive inside the declaring assembly |
How I Tested This
Console apps on the .NET 11 RC2 SDK (11.0.100-rc.2.26504.105, runtime 11.0.0-rc.2.26504.105) on Windows 11. Each compiler message comes from a separate build of a one-file variant, and the run output is copied from the console. The type shapes come from reflection over the RC2 runtime. The official references are What's new in C# 15, the union types reference and the closed modifier.
Related posts: C# 14 and C# 13 new features · C# generics: constraints and errors · System.Text.Json vs Newtonsoft · all C# tutorials
Comments
Post a Comment