Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docfx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions docs/csharp/language-reference/builtin-types/record.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ f1_keywords:
helpviewer_keywords:
- "record keyword [C#]"
- "record type [C#]"
ai-usage: ai-assisted
---
# Records (C# reference)

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/csharp/language-reference/keywords/abstract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
87 changes: 87 additions & 0 deletions docs/csharp/language-reference/keywords/closed.md
Original file line number Diff line number Diff line change
@@ -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<int>`), as opposed to an *open type* like `C<T>`. 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.
Comment thread
BillWagner marked this conversation as resolved.

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)
3 changes: 2 additions & 1 deletion docs/csharp/language-reference/keywords/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/csharp/language-reference/keywords/sealed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<TargetFramework>net11.0-windows</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<StartupObject>Keywords.Program</StartupObject>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace LanguageKeywords.ClosedHierarchies;

// Setup types reused by the snippets in this file.
public closed record class GateState;

Check failure on line 4 in docs/csharp/language-reference/keywords/snippets/shared/Closed.cs

View workflow job for this annotation

GitHub Actions / snippets-build

D:\a\docs\docs\docs\csharp\language-reference\keywords\snippets\shared\Closed.cs(4,22): error CS1002: ; expected [D:\a\docs\docs\docs\csharp\language-reference\keywords\snippets\keywords_yhrjhcli_wpftmp.csproj]
public record class Closed : GateState;
public record class Open(float Percent) : GateState;

//<GenericRule>
public closed class C<T> { }

Check failure on line 9 in docs/csharp/language-reference/keywords/snippets/shared/Closed.cs

View workflow job for this annotation

GitHub Actions / snippets-build

D:\a\docs\docs\docs\csharp\language-reference\keywords\snippets\shared\Closed.cs(9,8): error CS0116: A namespace cannot directly contain members such as fields, methods or statements [D:\a\docs\docs\docs\csharp\language-reference\keywords\snippets\keywords_yhrjhcli_wpftmp.csproj]

public class D1<U> : C<U> { } // OK: 'U' appears in the base class
public class D2<V> : C<V[]> { } // OK: 'V' appears in the base class
// public class D3<W> : C<int> { } // Error: 'W' isn't used in the base class
//</GenericRule>

public static class ClosedSwitchExamples
{
//<ExhaustiveSwitch>
public static string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
// No warning: every direct descendant of 'GateState' is handled.
};
//</ExhaustiveSwitch>

//<NullableSwitch>
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.
};
//</NullableSwitch>

//<TypeParameterConstrained>
public static string DescribeGate<X>(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.
};
//</TypeParameterConstrained>
}
57 changes: 57 additions & 0 deletions docs/csharp/language-reference/operators/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading