Closed Hierarchies in C# 15: Exhaustive Switches and Versioning
Every C# developer who has worked with a state machine, a command hierarchy, or an AST has written this pattern match at some point:
string Describe(PaymentMethod method) => method switch
{
Cash => "cash",
Card card => $"card ending in {card.Last4}",
_ => throw new NotSupportedException($"Unhandled payment method: {method}")
};
That _ => throw arm is not defensive programming. It is an admission that the compiler has no idea whether Cash and Card are the only two subtypes of PaymentMethod that will ever exist, so it forces you to write a runtime safety net for a case that, in a well-behaved codebase, should never trigger. Add a third subtype next month, forget to update this switch, and you find out at runtime, ideally in a test, less ideally in production, in whichever environment happens to have the most nervous stakeholders watching the dashboard that day.
This is the second post in a short series on candidate and preview C# 15 language features. The first one covered union types; if you read it, you know I take some convincing before I get excited about new syntax. Closed hierarchies earned it. It solves a real, common, and annoying problem, the same one that has cost me a debugging session or two over the years, always at the point where the fifth or sixth case gets bolted onto a switch someone else wrote two years earlier and stopped thinking about. It also comes with a trade-off I think public library authors should think through before reaching for it, so let’s look at both sides.
What Closed Hierarchies Actually Do
Before the opinion, the status, because “C# is getting X” has meant “someone opened a csharplang issue three years ago and nothing happened since” often enough that the distinction is worth making explicitly. Closed hierarchies is not a proposal in that sense. It has an accepted spec, it is implemented in Roslyn, and as of C# 15 preview 5 (paired with .NET 11 SDK preview 5) Microsoft has written it up everywhere a feature gets written up when it is heading toward general availability: the language reference, the pattern reference, the closed keyword docs, the what’s new in C# 15 page, and a full walkthrough tutorial that builds a small smart-home sensor model end to end. You need LangVersion=preview to touch it, but it is a real, shipped, working preview feature. Not a wish list entry, not a Discord rumor.
The idea: mark a base type closed, and the compiler fixes the set of its direct descendants to whatever is declared in the same assembly at compile time. Once that set is fixed, a switch over the base type that covers every direct descendant is provably exhaustive, and the compiler lets you drop the discard arm entirely.
public closed record class PaymentMethod;
public record class Cash : PaymentMethod;
public record class Card(string Last4) : PaymentMethod;
public record class BankTransfer(string Iban) : PaymentMethod;
string Describe(PaymentMethod method) => method switch
{
Cash => "cash",
Card card => $"card ending in {card.Last4}",
BankTransfer transfer => $"transfer from {transfer.Iban}"
// No discard arm. The compiler knows these three are the only
// direct descendants of PaymentMethod and proves the switch complete.
};
Forget to handle a fourth payment method after adding it? That is a compile error, not a 2 a.m. page. That single property, turning “forgot to update the switch” from a runtime bug into a build failure, is the entire value proposition, and it is a legitimate one. Anyone who has maintained a command dispatcher, an interpreter’s AST, or a workflow state machine has been burned by exactly this class of omission. I certainly have, and more than once with the same state machine, which says more about me than about the language.
The design has real edges, not hand-waving, and I appreciate that the language team bothered to give each edge its own error code instead of one generic “invalid modifier combination” message that leaves you guessing. closed is implicitly abstract, so you cannot instantiate the base type directly, and you cannot combine it with sealed, static, or an explicit abstract modifier: the compiler rejects those combinations in the CS938x range (CS9381 for the sealed/static conflict, CS9384 for the redundant abstract). Generics get their own rule: every type parameter of a derived type must show up in the base type reference, or you hit CS9383.
closed class Result<T> { }
class Success<T> : Result<T> { } // fine, T flows through
class Failure : Result<string> { } // fine, closes over a concrete type
// class Broken<T, U> : Result<T> { } // CS9383: U doesn't appear in the base reference
Cross-assembly visibility works the way you would hope, backed by its own error (CS9382, a closed type from another assembly cannot be used as a base type), though it will surprise people the first time they hit it:
public closed record class Shape;
public record class Circle : Shape;
internal record class Triangle : Shape;
A switch over Shape is only exhaustive without a discard arm inside the assembly that declares Triangle. Consumers in a different assembly can see Circle, cannot see Triangle, and therefore cannot know the hierarchy is actually closed from where they are standing. Their switch still needs a discard arm. That is correct behavior: accessibility should absolutely constrain exhaustiveness reasoning. But I would put money on a wave of Stack Overflow questions and internal Slack threads titled some version of “why does my switch still demand a default case”, because most developers do not intuitively separate “closed at the language level” from “closed as far as I can see”, and the compiler’s own error messages will need to work hard to close that gap in people’s heads.
One more preview-era wrinkle worth knowing before you try this: the compiler-emitted attribute that marks a type as closed has not landed in the BCL as of preview 5. The official tutorial has you paste in a polyfill named ClosedAttribute in System.Runtime.CompilerServices to get past the missing-type build error, while the published feature spec names the same mechanism IsClosedTypeAttribute. I would not bet on either name surviving untouched to general availability; that kind of naming churn between spec and tooling is exactly what you expect from a feature still this fresh. If you want to experiment today, copy whatever the tutorial currently ships and expect to delete it again in a later preview.
The Sibling Feature You’ll See Mentioned in the Same Breath
“Oh, like the new union types thing?” is the mix-up I expect this feature to run into constantly, and it is easy enough to make that I would rather draw the line here than let a conference speaker draw it fuzzily for you later. Closed hierarchies ships in the same C# 15 preview wave as a separate, complementary proposal for union types, and Microsoft’s own framing does at least try to keep them apart: closed hierarchies address the “restricted class hierarchy” shape of discriminated-union-style programming, unions address “wrap arbitrary unrelated types that share no base class.”
public union Pet(Cat, Dog);
string Describe(Pet pet) => pet switch
{
Dog d => d.Name,
Cat c => c.Name
};
I already covered union types in the first post of this series, and the distinction that matters in practice is ownership, not syntax. Closed hierarchies is exhaustiveness over an inheritance tree you already control, where Cash, Card, and BankTransfer all agree to be a PaymentMethod because you designed them that way. Union types is exhaustiveness over types that never agreed to anything, Cat and Dog do not know a Pet union exists and never will. If your model already has a natural base type, reach for closed. If you are gluing together types from a library you do not control, or types that have no business sharing an inheritance chain, reach for union. Expect conference talks and community write-ups to blur this line for at least a year anyway, because “C# gets discriminated unions” fits in a tweet and “C# gets two separate, narrower features that together cover some of the same ground” does not.
My Take: Useful Internally, Worth a Second Thought at Public API Boundaries
Here is where I land after reading the spec and the diagnostics list, not after playing with it in a sandbox for an afternoon.
What actually works: internal domain models. State machines, parser AST nodes, command and event hierarchies, anything that lives entirely inside one assembly and where the whole point is “these are the only shapes this can take.” I have personally built at least one hand-rolled switch-exhaustiveness analyzer for a project because the language gave me nothing better, and I am not proud of how much time that cost. Closed hierarchies fills that gap for the case that matters most: code you own end to end.
Where I would be more careful with the framing: calling this “C# gets sealed classes like Kotlin” or “C# finally has real discriminated unions” oversells it a little. Kotlin’s sealed class restricts derivation to the same module and gets exhaustive when for free, no second keyword required. Scala’s sealed trait is the closest conceptual ancestor, and its case classes add structural equality and value ergonomics on top that C#’s closed does not touch. F#’s discriminated unions are a first-class sum-type construct with structural equality and no inheritance-driven boxing tax by default. C#’s closed hierarchies is a reference-type-inheritance simulation of that idea, which means every case is still an allocation on the heap, there is no value-type case in the picture, and the restriction is only ever direct: a closed base’s grandchildren are not automatically covered, every intermediate level has to opt into closed again on its own. Java’s sealed interfaces and classes, finalized with exhaustive switch in Java 21, are the most direct analogue, and even there C# diverges: closed implies abstract, Java’s sealed does not, and Java’s permits clause is explicit where C# infers the descendant set from the assembly. None of this makes closed hierarchies bad. It is a solid tool, just narrower and more C#-shaped than a “we finally caught up to Kotlin” headline suggests. I will admit that needing a brand-new keyword, when sealed already meant “no derivation, period” for thirty years, took me a moment to get used to, and I suspect it will take other C# developers the same moment.
What could bite enterprise teams, and this is the part worth calling out explicitly: versioning discipline for public library authors. Picture a NuGet package that exposes a closed record class ApiResult with three direct descendants. Consumers write exhaustive switches with no discard arm, because the compiler told them it was safe to. Now the library adds a fourth descendant in what its changelog calls a minor version bump, because from the author’s side, it looks like adding a new capability, the same instinct that makes adding an enum member feel like a minor change today. Every consumer with an exhaustive switch over that closed hierarchy now fails to compile, on a Tuesday morning, in whatever CI pipeline happens to run the automated dependency update first. That is not a hypothetical: it is the exact same failure mode enum additions already cause, and closed hierarchies does not by itself enforce semver discipline for library authors. It sharpens the compile-time symptom for consumers, turning a runtime default fallthrough that used to silently do the wrong thing into a build break that at least fails loudly, which is an improvement, but only if the library author actually treats it as a breaking change and bumps major. Nothing in the design compels that. It is a documentation and discipline problem, same as it always was with enums, just with sharper teeth, and I say that as someone who has shipped an enum addition without thinking hard enough about who was switching on it.
The place I expect this to surface first is not application code at all but serialization, and it already has an open tracking issue to prove it: dotnet/runtime#129041 asks for System.Text.Json support for closed hierarchies specifically, because a closed base type is exactly the shape a JSON polymorphic contract wants to describe, and right now System.Text.Json has no native way to say “these are the only descendants, generate the discriminator accordingly.” Whoever owns that library integration is going to make the same public-API call every NuGet author will eventually face: does adding a case to a closed hierarchy that flows through your wire format count as a breaking change to your contract? I think the honest answer is yes, and I doubt every team building on top of this will treat it that way from day one.
The compiler team itself has not fully closed the book here either. The csharplang design notes flag open questions: whether closed hierarchies extends to interfaces at all remains an unresolved possible extension, not part of the shipped preview spec, and how variance interacts with closed class hierarchies is explicitly an area the language design meeting said it “needs to keep thinking about.” Both of those are signs the feature is stable in its current shape but not necessarily finished growing.
Where I Expect This to Land
Based on the docs shipping this deep, in an official tutorial and full language reference, alongside a defined error range, I would bet on this going final with C# 15 and .NET 11 general availability rather than slipping to a later version. That is my read of the maturity of what has already shipped in preview, not an official date, and I want to be explicit about that distinction because I have watched enough “obviously shipping next release” features get pulled at the last language design meeting to know better than to state it as fact. Microsoft has not published a GA date for this feature as such. The remaining risk I see is polish, not a redesign: the runtime attribute still needs to land in the BCL instead of living in everyone’s own polyfill file, and a handful of edge-case diagnostics may get renumbered before release. None of that is the kind of risk that reopens the core design at this stage.
The Practical Takeaway
Use closed freely for internal domain models: state machines, AST nodes, command/event hierarchies, anything living entirely inside one assembly where you want the compiler to catch “you added a case and forgot to handle it” at build time instead of at 2 a.m. That is a straightforward win with no real downside.
Think twice before marking a type in a public library API closed. If you do, treat adding a new direct descendant with the same seriousness you should already be treating a new enum member: a breaking change that demands a major version bump, documented clearly, because your consumers’ exhaustive switches will fail to compile the moment you add one, and the compiler will not remind you of that obligation on your side of the boundary. And if your hierarchy spans an internal/public accessibility split, budget for support tickets asking why the exhaustiveness check “isn’t working” for consumers who cannot see your internal descendants. That confusion is coming regardless of how well you document it.
There is one design move that actually softens this, and it is worth building into your library’s shape from day one rather than discovering it after the first breaking release: leave the leaf you expect to grow unsealed instead of closing every level of the hierarchy. Take Card from the earlier example. Leave it as an ordinary, unsealed type rather than sealing it alongside Cash, and a downstream assembly can derive its own PrepaidCard : Card without touching your closed PaymentMethod hierarchy at all. That new subtype still matches the existing Card arm in every consumer’s exhaustive switch: no recompilation, no version bump, no broken build. You trade some precision, the switch only ever sees Card, never PrepaidCard specifically, for an actual extension point instead of a wall. If you know which case in your hierarchy is going to sprout variants later, decide that now and leave it open on purpose. Sealing everything just because you can is how you turn a minor feature addition into next quarter’s major version bump.

Comments