C# 15 Union Types and Closed Hierarchies in Preview
If you have ever hand-rolled a Result type as a sealed abstract class with two private-constructor subclasses, or reached for the OneOf NuGet package because the language gave you nothing better, you already know the problem C# 15’s union types are aimed at. That gap has existed since a 2017 csharplang proposal first floated the idea, and it is finally something you can install and try.
This is the first of a short series looking at C# 15 candidate and preview language features: what they are, where the design actually stands, and whether they earn a spot in a production codebase or just a spot in a conference talk.
What Shipped, and Where
Two related but separately shippable features landed together in .NET 11 preview SDKs, with official documentation on Microsoft Learn covering union types and the unions proposal: nominal union type declarations, and a closed modifier for exhaustive class hierarchies. The runtime plumbing for unions (an IUnion interface and supporting attributes) arrived in .NET 11 Preview 5, and you need LangVersion=preview to compile any of it. Design work continues on the working group’s champion issue #8928, driven by a dedicated working group since roughly 2022, with Fred Silberberg as the named champion alongside Mads Torgersen and Matt Warren.
Here is the syntax as documented today:
public union Pet(Cat, Dog, Bird);
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
That single line generates a struct implementing IUnion with an implicit constructor for each case type. Pattern matching over it looks exactly like a normal switch expression, except the compiler now understands the closed set of possibilities:
var name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};
No default arm required. Add a fourth case type to the union declaration later, forget to update this switch, and the compiler warns you. That single behavior, compiler-verified exhaustiveness, is the entire pitch, and it’s a legitimate one. Nothing before this gave you that for free.
Case types are not limited to concrete classes. Generic type parameters work too, which is where the error-handling use case becomes obvious:
public union Result<TSuccess, TError>(TSuccess, TError);
public record class None;
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);
A Result<TSuccess, TError> return type forces every caller to handle both the success and error case in a switch, with the compiler flagging anything unhandled. That is the exact shape you were building by hand with a sealed abstract class and two private-constructor subclasses, or with OneOf.
The Catch: Unions of Types, Not of Cases
Here is where I want to slow down, because the enthusiasm around this feature, mine very much included, runs ahead of what it actually is. The official Microsoft Learn documentation says it plainly: unions in C# are unions of types, not discriminated or tagged unions in the F# sense.
That distinction matters more than it sounds. In F#, a discriminated union declares its cases and their fields inline as part of the type itself:
type Shape =
| Circle of float
| Rectangle of float * float
There is no separate Circle type floating around your codebase; the case and its data are defined together, once, as part of the union. C#’s union Pet(Cat, Dog, Bird) is a union of three pre-existing, independently usable types. That is a real feature, and a useful one, but it is not “F# discriminated unions with C# syntax,” which is roughly the pitch I would have made myself before reading past the headline. You still design and maintain Cat, Dog, and Bird as ordinary types; the union just wraps them.
The second catch is more concrete and more likely to bite you in production: boxing. The compiler-generated struct behind the one-line union declaration always stores its contents as object?, so any value-type case gets boxed on every access:
// Every read of the int or double case boxes it into object?.
public union Measurement(int, double);
To avoid that, you do not get a flag or an attribute to opt out. You either abandon the one-line declaration and hand-write the union struct yourself, or keep the union keyword and delegate to a nested IUnionMembers interface, writing only the TryGetValue overloads. Either way, implementing IUnion (directly or through the provider interface) plus a HasValue property and a TryGetValue(out T) overload per case is the non-boxing access pattern the official docs describe:
[System.Runtime.CompilerServices.Union]
public struct Measurement : System.Runtime.CompilerServices.IUnion
{
private readonly int _meters;
private readonly double _kilometers;
private readonly byte _tag; // 0 = none, 1 = meters, 2 = kilometers
public Measurement(int value) { _meters = value; _tag = 1; }
public Measurement(double value) { _kilometers = value; _tag = 2; }
public object? Value => _tag switch { 1 => _meters, 2 => _kilometers, _ => null };
public bool HasValue => _tag != 0;
public bool TryGetValue(out int value)
{
value = _meters;
return _tag == 1;
}
public bool TryGetValue(out double value)
{
value = _kilometers;
return _tag == 2;
}
}
Sit with that for a second. The entire point of a union type, in most enterprise pitches I have heard, is “less boilerplate than OneOf or a hand-rolled Result type.” For struct case types where allocation matters, you write almost the same boilerplate back in: it just moves from a third-party package to your own codebase. That is not nothing (a compiler-recognized shape for it is worth something), but it is not the free lunch I was hoping for.
There is also a genuine null footgun for struct unions: if the union’s value could be null, your switch needs an explicit null => arm, or the compiler will complain. We just spent a decade teaching developers that “the compiler will tell you if something can be null” is progress. Reintroducing a null-shaped hole in a brand-new type category, even with a diagnostic backstop, is the kind of thing I would happily skim past in a proposal doc and then rediscover six months later in a Slack thread titled “why is this switch not exhaustive.”
The Underrated Half: closed Hierarchies
If I had to bet enterprise budget on one of these two features today, it would not be union. It would be closed.
public closed record class PaymentMethod;
public record class Cash : PaymentMethod;
public record class CreditCard(string Last4) : PaymentMethod;
public record class BankTransfer(string Iban) : PaymentMethod;
This is not a new runtime concept. It is plain classes and records, marked with a modifier that tells the compiler “I am declaring the complete set of subtypes, right here, and I want you to enforce it.” Switch expressions over a closed hierarchy get the same exhaustiveness diagnostics as union (the proposal cites CS8509 and CS8510 for missing arms), but without a new runtime type, without an IUnion interface, without boxing questions, and without a null-arm surprise. It is closer to what a lot of teams are already doing with sealed abstract classes and private constructors, just with the compiler finally checking the thing that developer discipline was checking manually.
If your domain models a fixed set of behaviorally distinct types with their own methods and inheritance, reach for closed. If it models a fixed set of interchangeable data shapes you switch over, union is the better fit, boxing caveats included. Expect both idioms to coexist in the same codebase for a while, and expect a debate about which one a given Result<T, TError>-shaped return type should use. I do not think that debate resolves cleanly before GA.
My Expectation, and the Risk
What I expect to be genuinely useful: the closed modifier, on day one, for teams already modeling state machines and result types with sealed hierarchies. It formalizes a pattern most senior C# developers already reach for manually and gives it real diagnostics. Low migration cost, low conceptual overhead, no new runtime surface.
What I expect to be overhyped: union understood as “F# for C# developers.” It is not case-level pattern matching with inline fields; it is a compiler-recognized wrapper around existing types, with real allocation trade-offs for value-type cases that require you to hand-write the same non-boxing boilerplate you were trying to escape.
What could bite enterprise teams specifically:
- Timing. This missed C# 14 / .NET 10 entirely. It is targeted at C# 15 / .NET 11, with a November 2026 GA date reported by community sources (notably Maarten Balliauw’s June 2026 write-up), but that date is not confirmed on an official Microsoft release-schedule page as of this writing. Plan roadmaps around what has actually shipped, not around a date that has not yet appeared on an official release schedule.
- Design churn. As of this writing, only 4 of the 8 linked sub-proposals on the working group’s champion issue are closed (Custom Unions, Nominal Type Unions, the Non-Boxing Access Pattern, and Union Interfaces). Case Declarations, Closed Enums, Closed Hierarchies, and Standard Unions are still open. Core switch and pattern-matching semantics look stable across preview builds, but reflection support and serialization behavior remain unresolved open questions, and an ad hoc/anonymous union syntax (
A or B or C) is under discussion and not confirmed for the C# 15 GA scope at all. Preview means preview: do not put this in a library’s public API surface yet. - Idiom fragmentation. OneOf, hand-rolled
Result<T>types, sealed hierarchies, and now two new first-class options. Pick one direction per codebase and document why, or your code review comments will spend the next two years litigating which pattern a new PR should use.
Practical Takeaway
Do not migrate anything to union today; it is preview software with a fifth of its design questions still open, and the boxing story alone deserves a team discussion before adoption, not after. Do watch closed hierarchies closely: they are the lower-risk piece, they map onto patterns your team likely already uses, and they cost almost nothing to adopt once .NET 11 is generally available. When it does ship, budget time for a “which idiom for which domain shape” decision inside your team’s coding guidelines, not for a wholesale OneOf-to-union migration on day one. The compiler-checked exhaustiveness is real progress. The “just like F#” framing is not, and that distinction is worth carrying into the next architecture discussion.

Comments