Thursday, June 25, 2026

C# 15.0: Closed Class Hierarchies

In this post, let's have a look at a nice new C# language feature: Closed Class Hierarchies that's shipping as part of C# 15.

I am using .NET 11 Preview 5 (latest as of today, it will change) for this post.

You will also want to set the following in your project file to opt into the latest language features.
 <LangVersion>preview</LangVersion>
Let's start with the problem first. Say I have a simple type hierarchy of shapes and a switch expression that calculates the area.
Shape[] shapes =
[
    new Circle(2),
    new Rectangle(3, 4)
];

foreach (Shape shape in shapes)
{
    Console.WriteLine($"{shape.GetType().Name}: {Area(shape):0.00}");
}

static double Area(Shape shape) => shape switch
{
    Circle(var r) => Math.PI * r * r,
    Rectangle(var w, var h) => w * h
};

public abstract record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;
Here I am handling Circle and Rectangle, which are the only two subtypes of Shape that exist. But the compiler still gives me 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.
CS8509: The pattern '_' is not covered
The reason is, even though I have handled every subtype that exists today, the compiler has no way of knowing that. Someone could derive another type from Shape anywhere, so the compiler insists that I add a _ (discard) arm to be safe. And that's exactly the problem: the moment I add a _ => throw ... catch-all to silence this, I lose all compiler help. If I add a new subtype later and forget to handle it, the code happily compiles and falls into the catch-all at runtime.

This is where Closed class hierarchies come in. I just need to mark the base type with the new closed keyword.
// Omitted for brevity

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;
Now the CS8509 warning is gone, and notice I didn't have to add a _ arm at all.

A closed type can only be directly derived from within the same assembly, and it is implicitly abstract. Now the compiler knows the complete set of subtypes, so it can prove the switch expression is exhaustive.

And here is the best part. Let's say the requirements grow and I add a new shape, Triangle, but I forget to update the Area switch.
// Omitted for brevity

public closed record class Shape;

public record class Circle(double Radius) : Shape;

public record class Rectangle(double Width, double Height) : Shape;

public record class Triangle(double Base, double Height) : Shape;
Because Shape is closed, the compiler immediately tells me exactly what I missed.
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). 
For example, the pattern 'Triangle' is not covered.
CS8509: The pattern 'Triangle' is not covered.
Notice the difference: instead of the vague '_' is not covered, it now says 'Triangle' is not covered, naming the exact subtype I forgot, on every switch I need to update.

One thing while this is still in preview. The closed keyword needs a compiler-required attribute that the BCL doesn't ship yet, so you have to hand-roll it. If you don't, you'll get an error like this:
error CS0656: Missing compiler required member 'System.Runtime.CompilerServices.ClosedAttribute..ctor'
Interestingly, the well-known member name drifted across preview toolsets. The .NET SDK CLI compiler (I am on 11.0.100-preview.5) asks for ClosedAttribute, while my Visual Studio toolset asks for IsClosedTypeAttribute. To keep both happy, I just declared both in a separate file.
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class IsClosedTypeAttribute : Attribute { }
This is preview behavior and should get cleaned up as the feature stabilizes, but it's good to know if you want to try it out today.

Hope this helps.

Happy Coding.

Regards,
Jaliya

No comments:

Post a Comment