Closed Classes and Exhaustive Hierarchies in C# 15
Objective
A closed class, new in C# 15, is a base class whose complete list of direct
subclasses is known to the compiler, because only code in the same assembly can
derive from it. The payoff is in switch: a switch over a closed base that
handles each direct subclass is exhaustive, with no discard arm, even when the
base is a plain hierarchy and not an enum. Until C# 15 the compiler could not
promise that, so abstract base plus sealed children still produced CS8509
with the pattern '_', and teams wrote a _ => throw arm that hid every
subclass added later. This concept covers the modifier and its rules, how
exhaustiveness is computed (and where it has holes), the diagnostics, the
metadata, the System.Text.Json gotcha, and how closed compares with
sealed plus a private constructor, enum and union. Everything was compiled
and run on the 11.0.100 SDK.
Use Cases
- A domain hierarchy you own, such as shapes, payment events or gate states,
that other code consumes with
switch. - A contracts assembly that publishes a base type and subclasses, and consumer
assemblies that must handle all of them without a
_arm. - Replacing the
abstract recordplus_ => throw new UnreachableException()pattern with a compiler-checked version. - Choosing between a closed hierarchy and a
unionfor a closed set of cases.
Deep Dive
The modifier and its rules
csharpclosed record class GateState;
record class Closed : GateState;
record class Open(float Percent) : GateState;
GateState g = new Open(40f);
string text = g switch { Closed => "closed", Open(var pct) => $"open {pct}%" };
// "open 40%", compiled without a warningThe same hierarchy declared abstract on the base and sealed on the children
compiles with warning CS8509: ... the pattern '_' is not covered. The compiler
cannot rule out a subclass in another assembly. With closed it can, and the
warning disappears. Remove a case from the closed version and the warning comes
back naming the real gap: the pattern 'Rect' is not covered.
The rules, each one checked with a one-line program:
| Code | Result |
|---|---|
new Shape() on a closed class |
CS0144: it is abstract. closed implies abstract |
closed abstract class A; |
CS9384: always implicitly abstract |
closed sealed class A; or closed static class A; |
CS9381: cannot be sealed or static |
closed struct A;, closed interface A; |
CS0106: only classes (records included) |
class X : ClosedBase in another assembly |
CS9382: cannot use a closed type from another assembly as a base type |
class closed { } |
CS9380: types and aliases cannot be named closed |
The CS9382 case needs two projects, and the lab has them: a contracts assembly
with closed record class Shape, and an app assembly that cannot derive from it
but can switch over it. A closed class can also be a record class, which is
what you usually want for data.
How exhaustiveness is computed
The compiler checks the direct subclasses. Three consequences, each from a compiled example:
- A direct subclass that is not closed is covered by a pattern for itself. With
closed class Animaland childrenDogandCat, a switch withDogandCatis exhaustive, and aLeaf : Dogis covered by theDogarm. - A direct subclass that is itself
closedextends the check to its own children. Withclosed class Mid : Animalandclass Leaf : Mid, a switch with onlyDogandCatwarnsthe pattern 'Leaf' is not covered; addingLeaf l => 3silences it. - A direct subclass that is neither closed nor abstract can be instantiated
itself, so the switch needs an arm for it. With
closed class Shape, a plainclass Animal : Shape, andDogandCatbelowAnimal, a switch overShapewith arms forDogandCatonly warnsthe pattern 'Animal' is not covered.
So the discipline is: make every level that you want checked closed, or keep
the direct subclasses as the only concrete types.
Subsumption works as it does for any switch. If the base case comes first, the later arms are unreachable and the compiler says so as an error:
csharpShape s = new Circle(1);
s switch { Shape x => 0, Circle c => 1, Rect r => 2 };
// error CS8510: The pattern is unreachable.What the compiler writes
The compiler marks the closed class with IsClosedTypeAttribute and puts
CompilerFeatureRequired("ClosedClasses") on its constructors. The second one
stops a compiler that does not know the feature, and other languages, from
deriving from the class without the protection. The lab reads both from the
metadata with reflection and asserts them. Both are types from the .NET 11
runtime, which is the same lesson as for unions: with net10.0 as the target,
closed fails with CS0656: Missing compiler required member 'System.Runtime.CompilerServices.IsClosedTypeAttribute..ctor'. On C# 14 the
modifier is CS9327: Feature 'closed classes' is not available in C# 14.0.
System.Text.Json does not guess
A closed class is abstract, and System.Text.Json treats an abstract base as a
type with no discriminator unless told otherwise. With default options,
JsonSerializer.Serialize<Shape>(new Circle(2)) writes {} and
Deserialize<Shape> throws NotSupportedException. The option
InferClosedTypePolymorphism = true tells the serializer that the closed
class's direct subclasses are the polymorphic set:
csharpvar options = new JsonSerializerOptions { InferClosedTypePolymorphism = true };
JsonSerializer.Serialize<Shape>(new Circle(2), options);
// {"$type":"Circle","R":2}
JsonSerializer.Deserialize<Shape>("""{"$type":"Rectangle","W":3,"H":4}""", options);
// Rectangle { W = 3, H = 4 }The $type property appears in the payload, which is a contract with clients.
Treat the property name and the type names as public API, or write the
discriminators yourself with [JsonPolymorphic] and [JsonDerivedType].
Closed versus the alternatives
abstractplus a private constructor and nested sealed classes. This made the hierarchy closed by convention, but the compiler still wanted a_arm and gave CS8509 (seeresult-types-versus-exceptions).closedis that pattern, checked.sealedclasses behind an interface. Another assembly can implement the interface, so no exhaustiveness. Same limit.enum. Exhaustive in intent, but any integer is a legal enum value and the warning isCS8524.closed enumis only a proposal:closed enum ColorisCS0106on the GA compiler, even withLangVersionpreview. Seeenums-and-flags-enums.union. Chooses from types that need no shared base, including ones you do not own. A closed hierarchy needs a base you own and gives you inheritance and shared members. Seeunion-types-in-csharp-15.
Trade-offs
- Same assembly only. Consumers cannot add subclasses, which is the point, but
it means plugin-style extension is out. If extensions must exist, do not use
closed. - Adding or closing is a breaking change. Adding a subclass to a published
closed class makes every consumer's switch non-exhaustive, and adding
closedto an existing public class stops all external subclasses from compiling. Plan the contracts assembly's versioning for that. - Holes at non-closed middle levels. A middle class that is neither closed
nor sealed can be instantiated if it is not abstract, so the compiler asks for
an arm for it. Make intermediate classes
closedwhen you want the check to go deeper. - Runtime requirement. The attribute types ship with .NET 11. A library that
must target
net10.0cannot useclosedwithout a polyfill, and other languages cannot subclass it. - Serialization needs a decision. Turn on
InferClosedTypePolymorphismor write the discriminators yourself; the default output is{}. - Keep the discard arm out. Adding
_brings back the old problem: the new subclass compiles and silently takes the default branch.