Discriminated unions in C# and .NET 11 (for real this time) | Maarten Balliauw {blog} type or the OneOf NuGet package. Real C# language support (csharplang issue #113) had been open for years with no sign of a proper solution. Well, the wait is over. C# 15, shipping with .NET 11 (preview), introduces first-class union types. Let’s have a look."> type or the OneOf NuGet package. Real C# language support (csharplang issue #113) had been open for years with no sign of a proper solution. Well, the wait is over. C# 15, shipping with .NET 11 (preview), introduces first-class union types. Let’s have a look."> type or the OneOf NuGet package. Real C# language support (csharplang issue #113) had been open for years with no sign of a proper solution. Well, the wait is over. C# 15, shipping with .NET 11 (preview), introduces first-class union types. Let’s have a look.">
Go back
# General
# .NET
# dotnet<br>Discriminated unions in C# and .NET 11 (for real this time)<br>Maarten Balliauw · 16 Jun, 2026
Back in 2023, I wrote about discriminated unions in C# and how C# developers had to work around the lack of language support using things like ASP.NET Core’s Results<> type or the OneOf NuGet package. Real C# language support (csharplang issue #113) had been open for years with no sign of a proper solution.
Well, the wait is over. C# 15, shipping with .NET 11 (preview), introduces first-class union types. Let’s have a look.
A quick recap: the problem
My 2023 post centered on a common scenario: you have a method that can return one of several distinct types, and you want the compiler to know about all of them. Consider a RegisterUser() method that returns either a User, UserAlreadyExists, or InvalidUsername. These types don’t share a base class, and you just want to say “it’s one of these three” and have the compiler hold you to it.
ASP.NET Core Minimal APIs gave us Results<> as an approximation, where the result can be a Ok or a NotFound:
app.MapGet("/data", ResultsOkData>, NotFound> () =><br>return TypedResults.Ok(new Data());<br>});<br>The Results<> type is essentially a discriminated union: it knows which concrete types are valid, and the compiler would assist in letting you know that, in this case, BadRequest can not be an option. But there was a catch. If you changed GetData() to return one of four types instead of three, you would not get a compilation error in a downstream switch expression, telling you about a missing case. The exhaustiveness guarantee was not there, and that was the whole point of the 2023 post: the workarounds got you most of the way, but not all the way.
Declaring a union type
C# 15 closes the gap. The new union keyword (contextual, so it won’t break existing code named union) lets you declare a type that is exactly one of a set of cases:
public union Pet(Cat, Dog, Bird);<br>That single line tells the compiler that a Pet is either a Cat, a Dog, or a Bird, and nothing else. The compiler generates a value type (struct) under the hood that implements IUnion and holds the actual value.
Generic unions work the same way, and are immediately useful for result modeling:
public union ResultTSuccess, TError>(TSuccess, TError);<br>Now you have a proper Result type that the language understands natively, without pulling in a third-party library or hand-rolling an implicit operator for each case. See the C# 15 what’s new docs and the union type language reference for the full details.
Implicit conversions
Each case type gets an implicit conversion to the union type for free. No extra plumbing required:
Pet pet = new Dog("Rex"); // just works, no cast, no factory method<br>Compare that to the old approach where you’d write implicit operator overloads by hand for every case type, or use a library that generates them. The compiler handles it now: assign a Cat, a Dog, or a Bird, and you get a Pet.
This also means returning from methods is clean, without wrapping or manual conversions:
ResultUser, Error> Register(string username)<br>if (string.IsNullOrEmpty(username))<br>return new Error("Username cannot be empty");
return new User(username);<br>Exhaustive pattern matching (finally)
Pattern matching against a union type is exhaustive: the compiler warns you if you don’t handle all cases.
Forget one:
string Describe(Pet pet) => pet switch<br>Dog d => d.Name,<br>Cat c => c.Name,<br>// CS8509: switch expression does not handle all possible values of its input type (Bird)<br>};<br>You get CS8509, a clear warning that you missed Bird. Add it:
string Describe(Pet pet) => pet switch<br>Dog d => d.Name,<br>Cat c => c.Name,<br>Bird b => b.Name,<br>};<br>No warning, and no _ => throw new InvalidOperationException("unreachable") safety net cluttering the bottom of every switch. If a new case type is added to the union later, the compiler will flag every switch that doesn’t handle it.
Pattern matching with unwrapping
Patterns apply directly to the union, you don’t need to reach into .Value or .Result to get at the inner type. The compiler knows how to...