From 1860748d29e4a7795460c6f86f12488f10431d74 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 2 Jun 2026 11:22:46 -0400 Subject: [PATCH 01/10] Publish speclet Publish the feature spec for closed hierarchies. --- docfx.json | 5 ++++- docs/csharp/specification/toc.yml | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docfx.json b/docfx.json index 7c9ce03c942b8..3c45b68d5ef1d 100644 --- a/docfx.json +++ b/docfx.json @@ -52,6 +52,7 @@ "csharp-12.0/*.md", "csharp-13.0/*.md", "csharp-14.0/*.md", + "closed-hierarchies.md", "collection-expression-arguments.md", "unions.md" ], @@ -507,7 +508,7 @@ "_csharplang/proposals/csharp-12.0/*.md": "08/15/2023", "_csharplang/proposals/csharp-13.0/*.md": "10/31/2024", "_csharplang/proposals/csharp-14.0/*.md": "08/06/2025", - "_csharplang/proposals/*.md": "02/04/2025", + "_csharplang/proposals/*.md": "06/02/2025", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 7.md": "11/08/2022", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 8.md": "11/08/2023", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 9.md": "11/09/2024", @@ -645,6 +646,7 @@ "_csharplang/proposals/csharp-14.0/optional-and-named-parameters-in-expression-trees.md": "Optional and named parameters in expression trees", "_csharplang/proposals/collection-expression-arguments.md": "Collection expression arguments", "_csharplang/proposals/unions.md": "Unions", + "_csharplang/proposals/closed-hierarchies.md": "Closed hierarchies", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 7.md": "C# compiler breaking changes since C# 10", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 8.md": "C# compiler breaking changes since C# 11", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 9.md": "C# compiler breaking changes since C# 12", @@ -728,6 +730,7 @@ "_csharplang/proposals/csharp-14.0/optional-and-named-parameters-in-expression-trees.md": "This proposal allows an expression tree to include named and optional parameters. This enables expression trees to be more flexible in how they are constructed.", "_csharplang/proposals/collection-expression-arguments.md": "This proposal introduces collection expression arguments.", "_csharplang/proposals/unions.md": "This proposal describes union types and union declarations. Unions allow expressing values from a closed set of types with exhaustive pattern matching.", + "_csharplang/proposals/closed-hierarchies.md": "This proposal describes closed class hierarchies. A closed class restricts derivation to its declaring assembly, enabling exhaustive `switch` expressions over its direct descendants.", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 7.md": "Learn about any breaking changes since the initial release of C# 10 and included in C# 11", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 8.md": "Learn about any breaking changes since the initial release of C# 11 and included in C# 12", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 9.md": "Learn about any breaking changes since the initial release of C# 12 and included in C# 13", diff --git a/docs/csharp/specification/toc.yml b/docs/csharp/specification/toc.yml index 1892f47024fdb..953f89f85e37c 100644 --- a/docs/csharp/specification/toc.yml +++ b/docs/csharp/specification/toc.yml @@ -139,6 +139,8 @@ items: href: ../../../_csharplang/proposals/csharp-14.0/extensions.md - name: Extension operators href: ../../../_csharplang/proposals/csharp-14.0/extension-operators.md + - name: Closed hierarchies + href: ../../../_csharplang/proposals/closed-hierarchies.md - name: Structs items: - name: Inline arrays From 4b7a7823f2f6d75a89f1be5361c884ede27fabcf Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 2 Jun 2026 13:28:00 -0400 Subject: [PATCH 02/10] Add language reference article Add a new reference article for `closed`. Update snippets and the associated TOC. --- .../language-reference/keywords/closed.md | 79 +++++++++++++++++++ .../language-reference/keywords/index.md | 3 +- .../keywords/snippets/keywords.csproj | 2 +- .../keywords/snippets/shared/Closed.cs | 36 +++++++++ docs/csharp/language-reference/toc.yml | 3 + 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 docs/csharp/language-reference/keywords/closed.md create mode 100644 docs/csharp/language-reference/keywords/snippets/shared/Closed.cs diff --git a/docs/csharp/language-reference/keywords/closed.md b/docs/csharp/language-reference/keywords/closed.md new file mode 100644 index 0000000000000..c69ebdbdcfc69 --- /dev/null +++ b/docs/csharp/language-reference/keywords/closed.md @@ -0,0 +1,79 @@ +--- +title: "closed modifier" +description: "Learn about the closed class modifier in C#. A closed class restricts derivation to its declaring assembly so consumers can write exhaustive switch expressions over its direct descendants." +ms.date: 06/02/2026 +f1_keywords: + - "closed" + - "closed_CSharpKeyword" +helpviewer_keywords: + - "closed keyword [C#]" + - "closed class [C#]" + - "closed hierarchy [C#]" +ai-usage: ai-assisted +--- +# closed (C# Reference) + +Starting in C# 15, you can apply the `closed` modifier to a class to declare a *closed hierarchy*. A closed class can only be derived from within its declaring assembly. Because the set of direct descendants is fixed, a `switch` expression that handles each direct descendant exhausts the closed base type and doesn't need a default arm. + +[!INCLUDE[csharp-version-note](../includes/initial-version.md)] + +```csharp +// Assembly 1 +public closed record class GateState; +public record class Closed : GateState; +public record class Open(float Percent) : GateState; + +// Assembly 2 +public record class Locked : GateState; // Error: 'GateState' is a closed class +``` + +The same-assembly restriction applies only to *direct* descendants of the closed class. A class that derives from a closed class isn't itself closed unless you also mark it `closed`. Because `Closed` in the previous example is a plain record, another assembly can derive from it: + +```csharp +// Assembly 2 +public record class Locked : Closed; // OK: 'Closed' isn't sealed or closed +``` + +If you want to prevent derivation from `Closed` as well, declare it as `sealed` or `closed`. + +## Declaration rules + +The `closed` modifier is a class modifier: + +- A `closed` class is implicitly [`abstract`](abstract.md). You can't combine `closed` with `sealed`, `static`, or an explicit `abstract` modifier. +- A direct subtype of a closed class must be declared in the same assembly and module as the closed base class. +- A class that derives from a closed class isn't itself closed. Apply the `closed` modifier again if you want a derived class to also be closed. + +If a generic class directly derives from a `closed` class, every type parameter on the derived class must be used in the base class specification. This rule isn't about the `closed` modifier itself: a *closed constructed type* is a generic type whose type arguments are fully specified (such as `C`), as opposed to an *open type* like `C`. The rule ensures that each closed constructed type of the base class has exactly one corresponding closed constructed type among its direct descendants, so the compiler can reason about exhaustiveness. + +:::code language="csharp" source="./snippets/shared/Closed.cs" id="GenericRule"::: + +## Exhaustive switch expressions + +When a `switch` expression handles every direct descendant of a closed class, the compiler considers the switch exhaustive and doesn't generate a non-exhaustiveness warning: + +:::code language="csharp" source="./snippets/shared/Closed.cs" id="ExhaustiveSwitch"::: + +When the switch governing expression is nullable, `null` becomes another possible value that the switch must handle. A switch over `GateState?` is exhaustive only when it also covers `null`: + +:::code language="csharp" source="./snippets/shared/Closed.cs" id="NullableSwitch"::: + +If you omit the `null` arm, the compiler warns that the pattern `null` isn't handled. The same rule applies whether the closed type is a class or a struct lifted to a nullable type. + +For more information about how the compiler determines exhaustiveness, including how closed hierarchies interact with generic constraints and accessibility, see [Closed hierarchy patterns](../operators/patterns.md#closed-hierarchy-patterns). + +## Contextual keyword + +`closed` is a contextual keyword. It has special meaning only when it appears as a modifier on a class declaration. You can continue to use `closed` as an identifier in other contexts. If you need to use `closed` as an identifier in a position where the modifier would also be valid, prefix it with `@` (for example, `@closed`) to tell the compiler to treat it as an identifier rather than the modifier. + +## C# language specification + +For more information, see the [Closed hierarchies](~/_csharplang/proposals/closed-hierarchies.md) feature specification. + +## See also + +- [Inheritance](../../fundamentals/object-oriented/inheritance.md) +- [sealed](sealed.md) +- [abstract](abstract.md) +- [Pattern matching](../operators/patterns.md) +- [Switch expression](../operators/switch-expression.md) diff --git a/docs/csharp/language-reference/keywords/index.md b/docs/csharp/language-reference/keywords/index.md index 2e4f273d6634e..532ffd5eb4b5b 100644 --- a/docs/csharp/language-reference/keywords/index.md +++ b/docs/csharp/language-reference/keywords/index.md @@ -122,12 +122,13 @@ A contextual keyword provides a specific meaning in the code, but it isn't a res [`async`](async.md) [`await`](../operators/await.md) [`by`](by.md) + [`closed`](closed.md) [`descending`](descending.md) [`dynamic`](../builtin-types/reference-types.md) [`equals`](equals.md) - [`extension`](extension.md) :::column-end::: :::column::: + [`extension`](extension.md) [`field`](field.md) [`file`](file.md) [`from`](from-clause.md) diff --git a/docs/csharp/language-reference/keywords/snippets/keywords.csproj b/docs/csharp/language-reference/keywords/snippets/keywords.csproj index 4a1f8644d374d..79258a36ab01a 100644 --- a/docs/csharp/language-reference/keywords/snippets/keywords.csproj +++ b/docs/csharp/language-reference/keywords/snippets/keywords.csproj @@ -3,7 +3,7 @@ enable WinExe - net10.0-windows + net11.0-windows enable true Keywords.Program diff --git a/docs/csharp/language-reference/keywords/snippets/shared/Closed.cs b/docs/csharp/language-reference/keywords/snippets/shared/Closed.cs new file mode 100644 index 0000000000000..2f74ecccaa2d4 --- /dev/null +++ b/docs/csharp/language-reference/keywords/snippets/shared/Closed.cs @@ -0,0 +1,36 @@ +namespace LanguageKeywords.ClosedHierarchies; + +// Setup types reused by the snippets in this file. +public closed record class GateState; +public record class Closed : GateState; +public record class Open(float Percent) : GateState; + +// +public closed class C { } + +public class D1 : C { } // OK: 'U' appears in the base class +public class D2 : C { } // OK: 'V' appears in the base class +// public class D3 : C { } // Error: 'W' isn't used in the base class +// + +public static class ClosedSwitchExamples +{ + // + public static string Describe(GateState state) => state switch + { + Closed => "closed", + Open(var percent) => $"{percent}% open", + // No warning: every direct descendant of 'GateState' is handled. + }; + // + + // + public static string DescribeOrUnknown(GateState? state) => state switch + { + null => "unknown", + Closed => "closed", + Open(var percent) => $"{percent}% open", + // No warning: every direct descendant of 'GateState' is handled, and null is handled. + }; + // +} diff --git a/docs/csharp/language-reference/toc.yml b/docs/csharp/language-reference/toc.yml index 9f8589a04fb21..8f7dc25b827ef 100644 --- a/docs/csharp/language-reference/toc.yml +++ b/docs/csharp/language-reference/toc.yml @@ -116,6 +116,9 @@ items: - name: async href: ./keywords/async.md displayName: await + - name: closed + href: ./keywords/closed.md + displayName: closed class, closed hierarchy - name: const href: ./keywords/const.md - name: event From 0e7c0a051f17ed6e4505796756987c6487b0396d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 2 Jun 2026 14:48:54 -0400 Subject: [PATCH 03/10] Add pattern implications The `closed` hierarchy impacts pattern exhaustiveness and subsumption. Add a section to discuss that in the patterns reference article. --- .../language-reference/operators/patterns.md | 51 ++++++++++++ .../patterns/ClosedHierarchyPatterns.cs | 83 +++++++++++++++++++ .../snippets/patterns/patterns.csproj | 2 +- 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 docs/csharp/language-reference/operators/snippets/patterns/ClosedHierarchyPatterns.cs diff --git a/docs/csharp/language-reference/operators/patterns.md b/docs/csharp/language-reference/operators/patterns.md index ea57b9e570a9a..b5eb8f0e32766 100644 --- a/docs/csharp/language-reference/operators/patterns.md +++ b/docs/csharp/language-reference/operators/patterns.md @@ -300,6 +300,57 @@ You can also nest a subpattern within a slice pattern, as the following example For more information, see [List pattern](~/_csharpstandard/standard/patterns.md#11211-list-pattern) in the C# language specification. +## Closed hierarchy patterns + +Starting in C# 15, a `switch` expression whose governing type is a [`closed`](../keywords/closed.md) class is *exhaustive* when its arms handle every direct descendant of that class. The compiler doesn't require a default arm because the switch is exhaustive: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="GateStateTypes"::: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="DescribeGateState"::: + +A closed hierarchy switch is exhaustive only when every direct descendant is reachable from the location of the switch. If a direct descendant is less accessible than the closed base type and isn't visible at the switch site, the compiler treats it as unhandled and warns that the switch isn't exhaustive. + +For example, a closed `public` base class can have an `internal` direct descendant. Code in the same assembly sees the full set of descendants, but code in another assembly doesn't: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="ShapeTypes"::: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="ShapeAreaSameAssembly"::: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="ShapeNameCrossAssembly"::: + +To restore exhaustiveness in assembly 2, add a discard arm (`_ => ...`) or make every direct descendant at least as accessible as the closed base type. + +When the governing type is nullable, `null` is an additional value the switch must handle. A switch over `GateState?` that omits a `null` arm isn't exhaustive even when every direct descendant is matched. The same rule applies to closed structs lifted to nullable types. + +Derivation from a closed class isn't transitive: a non-closed descendant of a closed class can be derived from in other assemblies. The compiler only treats the *direct* descendants as the exhaustive set. To make a switch over a descendant also benefit from exhaustiveness checking, declare the descendant `closed` (or `sealed`). + +Because indirect descendants don't expand the exhaustive set of the closed base, you don't have to add an arm for every transitive subtype to satisfy exhaustiveness. Subsumption between arms still works the same way it does for any class hierarchy: an arm that matches a base type covers every subtype, and a later arm that matches one of those subtypes is unreachable. Consider a closed `Vehicle` whose direct descendants are `Car` and `Truck`, plus an indirect descendant `Sedan` declared in another assembly: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="VehicleTypes"::: + +A switch over `Vehicle` is exhaustive after it handles `Car` and `Truck`, even though `Sedan` exists. The `Car` arm covers every `Sedan` value: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="VehicleCategoryExhaustive"::: + +To dispatch on `Sedan` specifically, place its arm *before* the `Car` arm. The `Car` arm remains reachable because it still matches every `Car` that isn't a `Sedan`: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="VehicleCategorySedanFirst"::: + +Reversing those two arms produces a subsumption error, just as it would in any other class hierarchy. The compiler detects that the `Car` arm already covers `Sedan`: + +```csharp +string Category(Vehicle v) => v switch +{ + Car => "car", + Sedan => "sedan", // Error CS8510: the pattern is unreachable. It has already been handled by 'Car => ...'. + Truck => "truck", +}; +``` + +If you want exhaustiveness to follow the hierarchy further down, declare `Car` itself `closed`. The compiler then treats `Sedan` (and any other direct descendant of `Car`) as part of an exhaustive set rooted at `Car`, so a switch arm of `Car` no longer satisfies exhaustiveness on its own when other direct descendants of `Car` exist. Marking `Car` `closed` also makes it implicitly `abstract`, which means you can no longer create instances of `Car` directly. That might not fit your design. If you need `Car` to remain instantiable, leave it open and dispatch on the specific subtypes you care about by ordering arms as shown earlier. + +For more information, see the [closed modifier](../keywords/closed.md). For the specification, see [Closed hierarchies](~/_csharplang/proposals/closed-hierarchies.md). + ## Union patterns Starting with C# 15, when the incoming value of a pattern is a [union type](../builtin-types/union.md), patterns automatically *unwrap* the union. They apply to the union's `Value` property rather than the union value itself. This behavior makes the union transparent to pattern matching: diff --git a/docs/csharp/language-reference/operators/snippets/patterns/ClosedHierarchyPatterns.cs b/docs/csharp/language-reference/operators/snippets/patterns/ClosedHierarchyPatterns.cs new file mode 100644 index 0000000000000..ae1a49122036b --- /dev/null +++ b/docs/csharp/language-reference/operators/snippets/patterns/ClosedHierarchyPatterns.cs @@ -0,0 +1,83 @@ +namespace Patterns.ClosedHierarchy; + +// +public closed record class GateState; +public record class Closed : GateState; +public record class Open(float Percent) : GateState; +// + +public static class GateStateExamples +{ + public static string Run(GateState state) => Describe(state); + + // + public static string Describe(GateState state) => state switch + { + Closed => "closed", + Open(var percent) => $"{percent}% open", + // No warning: every direct descendant of 'GateState' is handled. + }; + // +} + +// +// Assembly 1 +public closed record class Shape; +public record class Circle(double Radius) : Shape; +internal record class Triangle(double Base, double Height) : Shape; +// + +public static class ShapeExamples +{ + // + // Same assembly: 'Triangle' is visible, so the switch is exhaustive. + internal static double Area(Shape shape) => shape switch + { + Circle(var r) => Math.PI * r * r, + Triangle(var b, var h) => 0.5 * b * h, + }; + // + +#pragma warning disable CS8509 // Demonstrates the cross-assembly warning by example. + // + // Assembly 2 + public static string Name(Shape shape) => shape switch + { + Circle => "circle", + // Warning CS8509: the switch expression doesn't handle all possible values. + // 'Triangle' is a direct descendant of 'Shape' but isn't visible here. + }; + // +#pragma warning restore CS8509 +} + +// +// Assembly 1 +public closed record class Vehicle; +public record class Car(int Doors) : Vehicle; +public record class Truck(double PayloadTons) : Vehicle; + +// Assembly 2 +public record class Sedan(int Doors) : Car; +// + +public static class VehicleExamples +{ + // + public static string Category(Vehicle v) => v switch + { + Car => "car", + Truck => "truck", + // No warning. The 'Car' arm covers 'Sedan' through ordinary subtype matching. + }; + // + + // + public static string CategorySedanFirst(Vehicle v) => v switch + { + Sedan => "sedan", + Car => "car", // Reachable: 'Car' values that aren't 'Sedan'. + Truck => "truck", + }; + // +} diff --git a/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj b/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj index f177ef30084fe..d805edfa26c42 100644 --- a/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj +++ b/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj @@ -3,7 +3,7 @@ Exe enable - net9.0 + net11.0 enable Patterns From 2ba335baf5e1452e4a6c59730449f4b885d017a2 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Tue, 2 Jun 2026 14:55:19 -0400 Subject: [PATCH 04/10] Add `closed` to What's new Add the `closed` contextual keyword to the What's new in C# 15 article. --- docs/csharp/whats-new/csharp-15.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/csharp/whats-new/csharp-15.md b/docs/csharp/whats-new/csharp-15.md index 504a1e4852ac3..3867f1a53d153 100644 --- a/docs/csharp/whats-new/csharp-15.md +++ b/docs/csharp/whats-new/csharp-15.md @@ -12,6 +12,7 @@ C# 15 includes the following new features. Try these features by using the lates - [Collection expression arguments](#collection-expression-arguments) - [Union types](#union-types) +- [Closed hierarchies](#closed-hierarchies) C# 15 is the latest C# preview release. .NET 11 preview versions support C# 15. For more information, see [C# language versioning](../language-reference/configure-language-version.md). @@ -71,6 +72,31 @@ Union types first appeared in .NET 11 Preview 2. In early .NET 11 previews, the For more information, see [Union types](../language-reference/builtin-types/union.md) in the language reference or the [feature specification](~/_csharplang/proposals/unions.md). +## Closed hierarchies + +Starting in C# 15, you can apply the `closed` modifier to a class to declare a *closed hierarchy*. A closed class can only be derived from within its declaring assembly, which fixes the set of direct descendants at compile time: + +```csharp +public closed record class GateState; +public record class Closed : GateState; +public record class Open(float Percent) : GateState; +``` + +Because the compiler knows every direct descendant, a `switch` expression that handles each one is exhaustive and doesn't need a default arm: + +```csharp +string Describe(GateState state) => state switch +{ + Closed => "closed", + Open(var percent) => $"{percent}% open", + // No warning: every direct descendant of 'GateState' is handled. +}; +``` + +The `closed` modifier is a contextual keyword. A `closed` class is implicitly `abstract` and can't be combined with `sealed`, `static`, or an explicit `abstract` modifier. Derivation isn't transitive: a non-closed descendant of a closed class can still be derived from in other assemblies. To extend exhaustiveness checking down the hierarchy, mark intermediate descendants `closed` as well. + +For more information, see the [closed modifier](../language-reference/keywords/closed.md) and [Closed hierarchy patterns](../language-reference/operators/patterns.md#closed-hierarchy-patterns) in the language reference, or the [feature specification](~/_csharplang/proposals/closed-hierarchies.md). +