Closed class hierarchies: Exploring the .NET 11 preview – Part 4

C# 15, shipping alongside .NET 11, adds a new closed modifier for classes and records. This post is part of a series exploring .NET 11 preview features, and this piece looks specifically at what a closed class hierarchy is, how it differs from things we could already do with private constructors, and why it matters for switch expressions. If you write domain models with a fixed set of subtypes, such as payment methods, workflow states, or platform variants, this feature is worth understanding before .NET 11 ships.

What a closed class hierarchy actually is

A closed class hierarchy is a hierarchy that can only be defined inside a single assembly. If someone outside that assembly tries to derive a new class from a closed base type, the build fails. Here is the basic idea, with all classes in the same assembly.

// Create a closed base class
public closed class Animal { }
 
// Each class derives from the closed Animal class
public class Dog : Animal { }
public class Cat : Animal { }
public class Horse : Animal { }

Dog, Cat, and Horse deriving from Animal is ordinary C#. The closed keyword does not change anything about this part. The difference shows up the moment someone tries to derive from Animal in a second assembly.

// Assembly 2
public class Cow : Animal { }

This will not compile. You get an error along these lines.

error CS9382: 'Cow': cannot use a closed type 'Animal' from another assembly as a base type.

So a closed base type gives you a public type that other assemblies can consume and reference, but nobody outside your own assembly can extend it. That is useful on its own for locking down a domain model, but the real payoff is something else entirely, which we get to shortly.

Isn’t this the same as a private constructor?

Preventing derived types is not new. Giving the base class a private protected or internal constructor has always made it practically impossible for anyone outside the assembly to create a working subclass. Update Animal like this.

// Assembly 1
// remove 'closed', make abstract, and add constructor
public abstract class Animal
{
    private protected Animal()
    {
    }
}

Trying to derive Cow from a different assembly still fails, just with a different pair of errors.

error CS0122: 'Animal.Animal()' is inaccessible due to its protection level
error CS1729: 'Animal' does not contain a constructor that takes 0 arguments

closed reads better than a constructor trick that exists purely to block inheritance, but on the surface it looks like it is not doing anything new. The real difference is what the compiler knows, not what the runtime enforces. With the private constructor approach, the compiler cannot actually prove that no other derived type exists anywhere, because in principle someone could still find a workaround. With closed, the compiler tracks every derived type in the assembly and knows with certainty that the list is complete. That certainty is what enables exhaustiveness checking, which is the part of this feature that actually changes how you write code day to day.

Why exhaustiveness checking in switch expressions is the real feature

Take a normal hierarchy, this time using abstract instead of closed.

// Abstract base type
public abstract class Animal { }
 
// The same derived types as before
public class Dog : Animal { }
public class Cat : Animal { }
public class Horse : Animal { }

Now write a method that switches over an Animal instance.

static string Speak(Animal animal) => animal switch
{
    Dog => "Woof",
    Cat => "Meow",
    Horse => "Neigh",
};

This compiles, but with a warning.

warning CS8509: The switch expression does not handle all possible
    values of its input type (it is not exhaustive). For example,
    the pattern '_' is not covered.

You know you have covered every subtype that exists in your codebase, but the compiler does not know that. As far as it is concerned, nothing stops another assembly from adding a fourth subclass and passing an instance of it into Speak. Even adding the private protected constructor from the previous section does not silence this warning, because the compiler still cannot infer that only three implementations will ever exist.

In real projects, the common response to this warning is to bolt on a catch-all arm just to keep the build clean.

static string Speak(Animal animal) => animal switch
{
    Dog => "Woof",
    Cat => "Meow",
    Horse => "Neigh",
    _ => throw new InvalidOperationException(); // Can't be hit
};

This quiets the compiler, but it creates a trap. Six months later, someone adds a new subtype.

public class Hamster : Animal {}

Every existing switch expression, including Speak, still compiles without complaint. Nothing tells you that Speak now has a code path that throws for a perfectly valid Animal. You find out only when a Hamster shows up in production and the exception fires. This is the exact failure mode that exhaustiveness checking exists to prevent, and it is worth flagging as a common mistake teams make with catch-all arms in general: a catch-all that exists only to satisfy the compiler tends to hide exactly the bugs you most want the compiler to catch.

This pattern, where the data types and the behaviour that switches over them live in separate places, is a functional-programming style of modeling. An object-oriented equivalent would put Speak as a virtual method on Animal itself, overridden per subtype, and would not need a switch at all. Both approaches are legitimate, and which one fits depends on whether new behaviours or new subtypes are added more often in your codebase.

With closed, the catch-all is no longer necessary.

// Animal is closed
public closed class Animal { }
 
static string Speak(Animal animal) => animal switch
{
    Dog => "Woof",
    Cat => "Meow",
    Horse => "Neigh",
    // We no longer need a fallback clause
};

Add Hamster now, and the compiler immediately flags every switch expression that needs updating.

warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Hamster' is not covered.

That is the entire value proposition in one line: adding a new case to a closed hierarchy surfaces every place in your codebase that needs to handle it, at compile time, instead of silently passing and blowing up later.

How this relates to C# union types

C# 15 also brings union types, covered elsewhere in this series, which give you similar exhaustiveness checking for a different shape of problem.

// Three different cases
public record Windows(string Version);
public record Linux(string Distro, string Version);
public record MacOS(string Name, int Version);
 
// Combined in a union, which can represent one of them
public union SupportedOS(Windows, Linux, MacOS);
 
// Use the union in a switch expressions
string GetDescription(SupportedOS os) => os switch
{
    Windows windows => $"Windows {windows.Version}",
    Linux linux => $"{linux.Distro} {linux.Version}",
    MacOS macOS => $"MacOS {macOS.Name} ({macOS.Version})",
}; // note: no catch-all _ required

Both features remove the need for a catch-all arm, but they solve different problems. A union defines an exact closed set of unrelated types with no inheritance relationship between them, which is a good fit when the cases genuinely have nothing in common structurally. A closed hierarchy is for when you already have, or want, an actual inheritance relationship, shared base members, virtual methods, and polymorphism, and you just want to guarantee that the set of derived types cannot grow outside your assembly. If your model is inheritance-shaped, reach for closed. If it is a plain either-or-or, a union is usually the cleaner fit.

Setting up closed class hierarchies in .NET 11 preview

closed shipped starting with .NET 11 preview 5, so you need preview 5 or later installed. Depending on your setup, you may also need a global.json with allowPrerelease set to true so your tooling actually picks up the preview SDK instead of falling back to an installed stable one.

Even with the right SDK installed, using the closed keyword straight away gives you two errors.

error CS8652: The feature 'closed classes' is currently in Preview and *unsupported*. To use Preview features, use the 'preview' language version.
error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.ClosedAttribute..ctor'

Fixing this needs two changes.

  • Add <LangVersion>preview</LangVersion> to your csproj
  • Manually define the [Closed] attribute yourself

The LangVersion change goes in your PropertyGroup.

<Project Sdk="Microsoft.NET.Sdk">
 
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
 
    <!-- Add this -->
    <LangVersion>preview</LangVersion>
  </PropertyGroup>
 
</Project>

The second requirement is only needed because preview 5 does not yet ship the attribute in the runtime itself. Define it manually in your project.

namespace System.Runtime.CompilerServices;
 
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
internal sealed class ClosedAttribute : Attribute { }

This is a temporary workaround. Expect it to go away in preview 6 or later once the BCL ships the attribute directly. Given this is still a preview feature with moving setup steps, treat it as something to experiment with in side projects rather than something to introduce into a production codebase just yet.

Rules and restrictions worth knowing before you use it

Marking a class or record as closed implicitly makes it abstract. That means you cannot also mark it sealed or static, and you cannot explicitly mark it abstract either, even though it effectively is one.

// Valid definitions
public closed class Animal { }
public closed record Animal { }
 
// Invalid definitions
public closed abstract class Animal { } // error CS9384: a closed type cannot be marked abstract because it is always implicitly abstract.
public closed sealed record Animal { } // error CS9381: a closed type cannot be sealed or static
public closed static record Animal { } // error CS9381: a closed type cannot be sealed or static

When you derive from a closed type, the derived class is not automatically closed itself. You can choose to make it closed as well if you want to build a multi-level closed hierarchy.

public closed record Animal { }
 
// Dog is not automatically closed, but can be made closed
public closed record Dog : Animal{ }
public record Labrador : Dog { }
public record Collie : Dog { }

Generic closed types are allowed, but if a derived class is also generic, every one of its type parameters must show up in how it specifies the base class.

public closed class Animal<T> { }
class Dog<U> : Animal<U> { }   // Ok, because 'U' is used in base class
class Cat<V> : Animal<V[]> { } // Ok, because 'V' is used in base class
class Horse<W> : Animal<int> { } // error CS9383: 'Horse<W>': The type parameter 'W' must be referenced in the base type 'Animal<int>' because the base type is closed.

This restriction exists so the compiler can still enumerate a finite set of concrete derived types. If Horse<W> could bind to Animal<int> for any W without W appearing in the base specification, the compiler would effectively be looking at an infinite family of derived types, which defeats the entire point of closed. The one restriction that is easy to forget in a larger codebase is that the whole hierarchy, base and every derived type, must live in a single assembly. Splitting a closed hierarchy across a core assembly and a plugin assembly, which is a common layering pattern, will not work.

Sealed hierarchies push the guarantee even further

If every derived type of a closed class is also marked sealed, you have what the feature calls a sealed hierarchy. In this case the compiler can make stronger assumptions and catch a class of bugs that would otherwise only show up at runtime.

public closed class Animal { }
 
// All derived types are sealed
public sealed class Dog : Animal { }
public sealed class Cat : Animal { }
public sealed class Horse : Animal { }

Add a simple interface that none of the derived types implement.

public interface IPet { }

Now try an explicit cast from Animal to IPet.

Animal animal = GetSomeAnimal();
var pet = (IPet)animal; // error CS0030: Cannot convert type 'Animal' to 'IPet'

Because the compiler can see every concrete type that Animal could possibly be, and none of them implement IPet, it knows this cast can never succeed and rejects it at compile time. Without the sealed hierarchy, this same cast would compile fine and only fail with an InvalidCastException when it actually ran. Turning a guaranteed runtime failure into a compile-time error is a genuinely useful safety net, particularly in larger teams where nobody remembers every implementation detail of every type in a shared library.

How the compiler implements closed under the hood

The mechanism behind closed is fairly simple once you see it. Take the plain example again.

public closed class Animal { }

The compiler emits something close to this.

[Closed] // Marks the type as a closed type
public class Animal
{
    [CompilerFeatureRequired("ClosedClasses")] // Tells the compiler that it needs this feature
    public Animal() { }
}

The [Closed] attribute tells any assembly that references this type that it is closed and cannot be derived from, which is why you had to define that attribute manually in preview 5, since the compiler references it in generated code. The [CompilerFeatureRequired] attribute is the more interesting piece. It stops an older SDK, one that has no idea what closed hierarchies are, from silently ignoring the restriction and letting someone derive from your closed type anyway.

Picture a concrete scenario: you build a library targeting net8.0 using the .NET 11 SDK so it stays usable across older runtimes, and you hand the compiled assembly to another team. That team is still on the .NET 8 SDK. If they try to derive from your closed type, their compiler blocks the attempt, even though their SDK does not understand the [Closed] attribute at all, because it recognizes the CompilerFeatureRequired marker and refuses to proceed. To be clear, this only blocks the closed feature on old SDKs. You can still target older runtimes just fine as long as you build with the .NET 11 SDK and have LangVersion set correctly, adding whatever polyfill attributes your target runtime is missing (a library like Simon Cropp’s Polyfill handles this automatically if you would rather not do it by hand).

Practical takeaways

closed is a small addition on paper, but it fixes a real gap that most C# codebases have lived with for years: there was never a compiler-verified way to say a hierarchy is complete. Before this, teams either lived with catch-all throw clauses that silently rot, or reached for third-party libraries like OneOf to fake a discriminated union over a class hierarchy. If your domain naturally fits an inheritance model, closed gives you the exhaustiveness guarantee without those workarounds.

Since this is still a preview feature as of .NET 11 preview 5, do not ship it in anything you plan to release to production yet. The setup itself, the manual ClosedAttribute definition, the LangVersion flag, is expected to simplify or disappear in later previews, so code you write against preview 5 may need small adjustments before general availability. For prototypes, internal tools, or getting a feel for how exhaustiveness checking will change your switch expressions, it is worth trying now. For anything shipping to customers this year, wait for a stable release.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading