TL;DR: C# 15 adds union types: union LookupResult(Found, NotFound, Invalid);. Assign any case type, switch over it, and the compiler checks you handled every case. It ships with .NET 11, which is at Release Candidate 1 right now: go-live licensed, but not final yet.
The arm that says "unreachable, promise"
You have a method that can end three ways. You want callers to handle all three. So you reach for the classic C# workaround: a marker interface.
ILookupResult result = new Discontinued("OLD-1");
string message = result switch
{
Found f => $"{f.Sku}: {f.Stock} in stock",
NotFound n => $"{n.Sku}: no such product",
Invalid i => $"rejected: {i.Reason}",
_ => throw new InvalidOperationException("unreachable, promise"),
};
interface ILookupResult;
record Found(string Sku, int Stock) : ILookupResult;
record NotFound(string Sku) : ILookupResult;
record Invalid(string Reason) : ILookupResult;
record Discontinued(string Sku) : ILookupResult;
The compiler cannot know that only three types implement the interface. So it demands the _ arm, and you write a throw for a case that "can't happen".
Then a teammate adds Discontinued. Everything compiles. When I ran it, the result was Unhandled exception. System.InvalidOperationException: unreachable, promise. The promise lasted exactly one pull request.
Meet union types
A union names a closed set of types. A value of the union is exactly one of them:
record Dog(string Name);
record Cat(string Name);
union Pet(Cat, Dog);
Any case type converts to the union implicitly, and pattern matching looks straight through it:
Pet pet = new Dog("Rex");
string name = pet switch
{
Dog d => $"dog {d.Name}",
Cat c => $"cat {c.Name}",
};
Console.WriteLine(name); // dog Rex
Console.WriteLine(pet.Value); // Dog { Name = Rex }
No _ arm, and no warning. The compiler knows Cat and Dog are the whole list.
Assigning anything else is a compile error: Pet p = "a string"; gives CS0029: Cannot implicitly convert type 'string' to 'Pet'.
The case types do not need a shared base class or interface. They do not even need to be yours.
A realistic one: a product lookup
Here is the same lookup from the top, as a union:
string[] inputs = ["ABC-123", "", "XYZ-9", "QQQ-404"];
foreach (var input in inputs)
{
LookupResult result = Lookup(input);
string message = result switch
{
Found f => $"{f.Sku}: {f.Stock} in stock",
NotFound n => $"{n.Sku}: no such product",
Invalid i => $"rejected: {i.Reason}",
};
Console.WriteLine(message);
}
static LookupResult Lookup(string sku)
{
if (string.IsNullOrWhiteSpace(sku))
return new Invalid("SKU is empty");
if (!sku.Contains('-'))
return new Invalid($"'{sku}' has no dash");
return sku switch
{
"ABC-123" => new Found(sku, 12),
"XYZ-9" => new Found(sku, 0),
_ => new NotFound(sku),
};
}
record Found(string Sku, int Stock);
record NotFound(string Sku);
record Invalid(string Reason);
union LookupResult(Found, NotFound, Invalid);
Output from the run:
ABC-123: 12 in stock
rejected: SKU is empty
XYZ-9: 0 in stock
QQQ-404: no such product
Each return hands back a plain record, and the union conversion does the rest.
Now the teammate adds Discontinued to the union. This time the build tells on them:
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Discontinued' is not covered.
That warning shows up at every switch that missed the new case. With TreatWarningsAsErrors on, it is a failed build instead of a 2 AM page.
Types you don't own
Because a union does not need inheritance, it can mix types you could never give a common base. Say your old users have int ids and new users have Guid ids:
UserId legacy = 1234;
UserId modern = Guid.Parse("3f2b8c1e-0000-4000-8000-000000000001");
Console.WriteLine(Show(legacy)); // legacy #1234
Console.WriteLine(Show(modern)); // user 3f2b8c1e000040008000000000000001
static string Show(UserId id) => id switch
{
int n => $"legacy #{n}",
Guid g => $"user {g:N}",
};
union UserId(int, Guid);
Try doing that with a marker interface. int will not sign up.
Can I use it?
- Union types are part of C# 15. On the .NET 11 RC1 SDK,
#error versionreportsLanguage version: 15.0, and every snippet here compiled with noLangVersionset. - The
UnionAttributeandIUniontypes the compiler relies on are in the runtime starting with .NET 11 Preview 5. - .NET 11 RC1 shipped on September 8, 2026 with a go-live support license, so it is allowed in production. It is still a release candidate, though, not the final release.
- Microsoft's docs note that some parts of the union proposal are not implemented yet and are coming later.
- On the .NET 10 SDK (C# 14),
unionis not a keyword at all, and the same file fails with syntax errors such asCS1001: Identifier expected.
I ran every snippet here as a file-based app with dotnet run file.cs on SDK 11.0.100-rc.1.
Gotchas
1. A union is a struct, and it holds an object?. The compiler turns union Pet(Cat, Dog) into a struct with one Value property of type object?. Unsafe.SizeOf<Pet>() printed 8 in my run: one reference, nothing more.
2. Value-type cases get boxed. Because Value is an object?, an int case is boxed on the way in. Storing 1,000 int values in a UserId allocated 24,000 bytes in my test, 24 bytes each. For hot paths, the docs show how to write a custom union with TryGetValue members that avoid the box.
3. default is a union with nothing inside. Pet nobody = default; has a Value of null. In my test, switching over it with only the Dog and Cat arms compiled with no warning and then threw SwitchExpressionException at runtime. If a union can come from default, an uninitialized field, or an array slot, add a null arm.
4. Its sibling is closed. If your cases already share a base class, C# 15 also has the closed modifier, which limits derived classes to the same assembly and gives you the same exhaustive switch. Use union when the cases have nothing in common, and closed when they do.
Wrap-up
A _ => throw arm is a comment that crashes. A union turns it into a compiler warning on the day the new case lands, which is the cheapest day to find it. Go find the "unreachable" exception in your codebase and see if it is really a union in disguise.
Sources
- What's new in C# 15 (Microsoft Learn)
- Union types, C# reference (Microsoft Learn)
- Feature specification: Unions
- The closed modifier (Microsoft Learn)
- Announcing .NET 11 Release Candidate 1 (.NET Blog)
This article was written with the help of AI and checked against the sources above.
Top comments (0)