diff --git a/docfx.json b/docfx.json index 7c9ce03c942b8..cd329615fd41a 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/2026", "_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/language-reference/builtin-types/record.md b/docs/csharp/language-reference/builtin-types/record.md index d767294c11b30..743a5d21268a3 100644 --- a/docs/csharp/language-reference/builtin-types/record.md +++ b/docs/csharp/language-reference/builtin-types/record.md @@ -7,6 +7,7 @@ f1_keywords: helpviewer_keywords: - "record keyword [C#]" - "record type [C#]" +ai-usage: ai-assisted --- # Records (C# reference) @@ -197,6 +198,8 @@ This section only applies to `record class` types. A record can inherit from another record. However, a record can't inherit from a class, and a class can't inherit from a record. +Starting in C# 15, a `record class` can use the [`closed`](../keywords/closed.md) modifier to restrict direct derivation to its declaring assembly. Consumers can then write exhaustive `switch` expressions over its direct descendants without a default arm. For details, see [Closed hierarchy patterns](../operators/patterns.md#closed-hierarchy-patterns). The `closed` modifier doesn't apply to `record struct` types, because struct records can't be abstract. + ### Positional parameters in derived record types The derived record declares positional parameters for all the parameters in the base record primary constructor. The base record declares and initializes those properties. The derived record doesn't hide them, but only creates and initializes properties for parameters that aren't declared in its base record. diff --git a/docs/csharp/language-reference/keywords/abstract.md b/docs/csharp/language-reference/keywords/abstract.md index 077c875b7a80a..09a2817639b92 100644 --- a/docs/csharp/language-reference/keywords/abstract.md +++ b/docs/csharp/language-reference/keywords/abstract.md @@ -102,4 +102,5 @@ The `Shape` class is declared `abstract`, which means you can't instantiate it d - [virtual](./virtual.md) - [override](./override.md) +- [closed](./closed.md) - [C# Keywords](./index.md) diff --git a/docs/csharp/language-reference/keywords/closed.md b/docs/csharp/language-reference/keywords/closed.md new file mode 100644 index 0000000000000..abaa081f47beb --- /dev/null +++ b/docs/csharp/language-reference/keywords/closed.md @@ -0,0 +1,87 @@ +--- +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). + +## Type parameters constrained to a closed type + +A type parameter constrained to a closed class is treated as that closed class for exhaustiveness checks. A `switch` expression whose governing value has such a type parameter is exhaustive when it handles every direct descendant of the closed constraint: + +:::code language="csharp" source="./snippets/shared/Closed.cs" id="TypeParameterConstrained"::: + +This rule applies whether the type parameter appears on a method or on the containing type. + +## 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/sealed.md b/docs/csharp/language-reference/keywords/sealed.md index 9220e2e3c8394..25fe74ac3345c 100644 --- a/docs/csharp/language-reference/keywords/sealed.md +++ b/docs/csharp/language-reference/keywords/sealed.md @@ -70,3 +70,4 @@ To determine whether to seal a class, method, or property, generally consider th - [Modifiers](index.md) - [override](override.md) - [virtual](virtual.md) +- [closed](closed.md) diff --git a/docs/csharp/language-reference/keywords/snippets/keywords.csproj b/docs/csharp/language-reference/keywords/snippets/keywords.csproj index 4a1f8644d374d..2fc665e2be532 100644 --- a/docs/csharp/language-reference/keywords/snippets/keywords.csproj +++ b/docs/csharp/language-reference/keywords/snippets/keywords.csproj @@ -3,7 +3,8 @@ enable WinExe - net10.0-windows + net11.0-windows + preview 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..cee2696614aa9 --- /dev/null +++ b/docs/csharp/language-reference/keywords/snippets/shared/Closed.cs @@ -0,0 +1,45 @@ +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. + }; + // + + // + public static string DescribeGate(X gate) where X : GateState => gate switch + { + Closed => "closed", + Open(var percent) => $"{percent}% open", + // No warning: 'X' is constrained to a closed type, so its direct descendants exhaust the switch. + }; + // +} diff --git a/docs/csharp/language-reference/operators/patterns.md b/docs/csharp/language-reference/operators/patterns.md index ea57b9e570a9a..e3e4186b59553 100644 --- a/docs/csharp/language-reference/operators/patterns.md +++ b/docs/csharp/language-reference/operators/patterns.md @@ -300,6 +300,63 @@ 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. + +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. + +### Type parameter governing types + +A `switch` expression whose governing type is a type parameter constrained to a closed class is exhaustive on the same terms as a switch over the closed class itself. To dispatch on a closed hierarchy from generic code, constrain the type parameter to the closed base and handle every direct descendant: + +:::code language="csharp" source="snippets/patterns/ClosedHierarchyPatterns.cs" id="TypeParamGoverningType"::: + +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..755b1f6459376 --- /dev/null +++ b/docs/csharp/language-reference/operators/snippets/patterns/ClosedHierarchyPatterns.cs @@ -0,0 +1,96 @@ +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", + }; + // +} + +public static class TypeParamGoverningTypeExamples +{ + // + public static string Describe(X gate) where X : GateState => gate switch + { + Closed => "closed", + Open(var percent) => $"{percent}% open", + // No warning: 'X' is constrained to the closed type 'GateState', + // so handling every direct descendant exhausts the switch. + }; + // +} diff --git a/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj b/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj index f177ef30084fe..7692041633b2d 100644 --- a/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj +++ b/docs/csharp/language-reference/operators/snippets/patterns/patterns.csproj @@ -3,7 +3,8 @@ Exe enable - net9.0 + net11.0 + preview enable Patterns 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 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 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). +