You have a base class with three derived types. You write a switch expression over them. The compiler complains that the switch is not exhaustive, so you add a discard arm that throws InvalidOperationException — a branch you know will never execute, but the compiler cannot prove it. Six months later, someone adds a fourth derived type in a different assembly. The discard arm silently swallows it. No warning, no build error, just a runtime crash when that code path finally gets hit in production.

This is the fundamental gap in C# inheritance hierarchies: the compiler has no way to know whether the set of derived types is complete. The sealed modifier prevents derivation entirely, but that is too aggressive when you genuinely want a hierarchy. What you need is a middle ground — a way to say "these are all the subtypes, and they all live here."

C# 15, shipping with .NET 11 later this year, fills that gap with the closed modifier. Mark a class as closed and derivation is restricted to the same assembly. The compiler can enumerate every direct descendant, which means switch expressions over a closed type are exhaustive without a default arm. No discard pattern, no runtime exceptions, no silent regressions when a new subtype is added.

The closed modifier

The syntax is minimal. Add closed before class (or record class) on your base type, and declare its subtypes in the same assembly:

Models/JobStatus.cs
public closed record class JobStatus;
public record class Queued : JobStatus;
public record class Running(int PercentComplete) : JobStatus;
public record class Completed(TimeSpan Elapsed) : JobStatus;
public record class Failed(string Error) : JobStatus;

Any attempt to derive from JobStatus in another assembly produces a compiler error:

Example.cs
// In a different assembly
public record class Paused : JobStatus; // Error CS9500: 'JobStatus' is a closed class

A closed class is implicitly abstract — you cannot instantiate the base type directly, only its concrete descendants. It cannot be combined with sealed, static, or an explicit abstract modifier. And closed is a contextual keyword, so existing code that uses closed as an identifier continues to compile without changes.

Exhaustive switch expressions

The payoff arrives the moment you write a switch:

Services/JobReporter.cs
public static string Describe(JobStatus status) => status switch
{
    Queued => "waiting to start",
    Running(var percent) => $"{percent}% complete",
    Completed(var elapsed) => $"finished in {elapsed.TotalSeconds:F1}s",
    Failed(var error) => $"failed: {error}",
};

No discard arm. No default. The compiler knows JobStatus is closed and that Queued, Running, Completed, and Failed are its only direct descendants, so those four arms exhaust the type. If you later add a Cancelled subtype to the assembly, every switch expression that does not handle it produces a compiler warning — exactly the behaviour you get with union types.

When the governing expression is nullable, null becomes another value that must be handled:

Example.cs
public static string DescribeOrUnknown(JobStatus? status) => status switch
{
    null => "unknown",
    Queued => "waiting to start",
    Running(var percent) => $"{percent}% complete",
    Completed(var elapsed) => $"finished in {elapsed.TotalSeconds:F1}s",
    Failed(var error) => $"failed: {error}",
};

Derivation is not transitive

An important subtlety: the closed restriction applies only to direct descendants of the closed class. A subtype that is not itself marked closed or sealed can be derived from in any assembly:

Example.cs
// Same assembly as JobStatus
public closed record class JobStatus;
public record class Failed(string Error) : JobStatus;

// Different assembly — this compiles
public record class RetryableFailed(string Error, int Attempts) : Failed(Error);

RetryableFailed is valid because Failed is a plain record class, not closed or sealed. The exhaustive switch over JobStatus still works — a RetryableFailed instance matches the Failed arm via inheritance. But if you want to prevent third parties from extending Failed at all, mark it sealed. If you want to create a nested closed hierarchy within Failed, mark it closed and add its own set of subtypes.

This gives you fine-grained control over each level of the hierarchy. The closed modifier at the top level guarantees that the compiler knows all direct branches. At each branch, you choose independently whether to close, seal, or leave it open.

How it differs from sealed

The distinction is worth spelling out. sealed prevents all derivation — no subclasses, period. closed allows derivation but restricts it to the same assembly. A sealed class has no subtypes; a closed class has a known, fixed set of subtypes.

Modifier Derivation allowed? Exhaustive switching? Implicitly abstract?
sealed No N/A (no subtypes) No
closed Same assembly only Yes Yes
abstract Anywhere No Yes

Think of closed as sitting between abstract and sealed on the openness spectrum. It gives you the polymorphism of an abstract hierarchy with the compiler guarantees of a sealed type.

How it differs from unions

C# 15 ships both closed hierarchies and union types, and they solve a similar problem — exhaustive pattern matching — through different mechanisms.

Unions compose existing, unrelated types into a closed set. The types do not share a base class. The union is a compiler-generated struct that wraps a Value property and provides implicit conversions.

Closed hierarchies use traditional OOP inheritance. The types share a common base class. They can have shared behaviour, virtual methods, and protected state. The exhaustiveness guarantee comes from the assembly restriction, not from a wrapper struct.

Use unions when you need a lightweight "one of these types" without shared behaviour — think Result<T> as a union of Success and Error. Use closed hierarchies when your types genuinely belong to an inheritance tree with shared methods and state — think a domain event hierarchy or a state machine.

Generics and closed classes

Closed classes work with generics, but there is one additional rule: if a generic class directly derives from a closed class, every type parameter on the derived class must appear in the base class specification. This ensures each closed constructed type of the base has exactly one corresponding derived type, so the compiler can reason about exhaustiveness.

Models/Tree.cs
public closed record class Tree<T>;
public record class Leaf<T>(T Value) : Tree<T>;
public record class Branch<T>(Tree<T> Left, Tree<T> Right) : Tree<T>;

Both Leaf<T> and Branch<T> use T in their base class specification (Tree<T>), so this compiles. A derivation that introduces an unrelated type parameter does not:

Example.cs
public record class Constant<U>(U Value) : Tree<int>; // Error: 'U' is not used in the base class

The compiler can also determine when certain generic instantiations make a subtype impossible. For instance, if Branch were defined as Branch<V> : Tree<V[]>, then a switch over Tree<string> would not require a Branch arm because no V satisfies V[] = string.

Constrained type parameters

A type parameter constrained to a closed class gets the same exhaustiveness treatment. This is particularly useful in generic methods:

Services/TreePrinter.cs
public static string Print<X>(X node) where X : Tree<int> => node switch
{
    Leaf<int>(var value) => value.ToString(),
    Branch<int>(var left, var right) => $"({Print(left)}, {Print(right)})",
};

No discard needed — X is constrained to Tree<int>, which is closed, so the switch is exhaustive.

Real-world example: domain events

Closed hierarchies shine in event-sourced systems where you want exhaustive handling of domain events:

Domain/OrderEvents.cs
public closed record class OrderEvent(Guid OrderId, DateTimeOffset OccurredAt);

public record class OrderPlaced(Guid OrderId, DateTimeOffset OccurredAt, decimal Total)
    : OrderEvent(OrderId, OccurredAt);

public record class OrderShipped(Guid OrderId, DateTimeOffset OccurredAt, string TrackingNumber)
    : OrderEvent(OrderId, OccurredAt);

public record class OrderCancelled(Guid OrderId, DateTimeOffset OccurredAt, string Reason)
    : OrderEvent(OrderId, OccurredAt);

public record class OrderRefunded(Guid OrderId, DateTimeOffset OccurredAt, decimal Amount)
    : OrderEvent(OrderId, OccurredAt);

The event projector handles every case without a fallback:

Domain/OrderProjector.cs
public class OrderProjector
{
    public OrderSummary Apply(OrderSummary current, OrderEvent evt) => evt switch
    {
        OrderPlaced e => current with { Total = e.Total, Status = "Placed" },
        OrderShipped e => current with { Status = "Shipped", TrackingNumber = e.TrackingNumber },
        OrderCancelled e => current with { Status = "Cancelled", CancelReason = e.Reason },
        OrderRefunded e => current with { RefundedAmount = current.RefundedAmount + e.Amount },
    };
}

When someone adds OrderDelivered to the events assembly, the compiler flags every projector, handler, and saga that does not cover it. This is exactly the kind of safety net that abstract base classes have always promised but never delivered.

The ClosedAttribute workaround

In .NET 11 Preview 5, the runtime does not yet ship System.Runtime.CompilerServices.IsClosedTypeAttribute. Until it does, you need to declare the attribute yourself in any project that uses closed:

Infrastructure/CompilerAttributes.cs
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class IsClosedTypeAttribute : Attribute;

This is a stopgap. The attribute will ship in a future preview, at which point you can delete the local declaration. The compiler also emits [CompilerFeatureRequired("ClosedClasses")] on the constructors of closed classes, which prevents older compilers that do not understand the feature from accidentally deriving from them.

Common pitfalls

Forgetting that derivation is not transitive. Marking a base class as closed does not automatically close its descendants. If Failed is open, anyone can derive RetryableFailed from it in another assembly. That derived type will match the Failed arm in your switch, which might not be the behaviour you expect. Decide explicitly at each level whether to use closed, sealed, or leave the type open.

Combining closed with explicit abstract. The compiler rejects abstract closed class — a closed class is already implicitly abstract. If you have existing code with abstract on a base class you want to close, remove the abstract modifier when you add closed.

Expecting interface exhaustiveness. The closed modifier currently applies only to classes. You cannot close an interface. The language specification notes this as a potential future extension, but for now, if you need exhaustive switching, use a closed class or a union.

Ignoring accessibility in exhaustiveness. If a subtype of a closed class is less accessible than the consuming code (for example, a protected nested class), the compiler cannot include it in exhaustiveness checks at that call site. The switch will require a default arm or a base-type pattern to suppress the warning.

Breaking consumers when adding subtypes. Adding a new direct descendant to a closed class is a source-breaking change for every consumer that has an exhaustive switch over the type. This is intentional — it is the whole point of the feature — but it means you should treat the subtype set as part of your public API contract. Adding a subtype to a closed class in a library is equivalent to adding a member to an enum: existing consumers must update their code.

Summary