Skip to content
Open
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
11 changes: 7 additions & 4 deletions docs/csharp/language-reference/builtin-types/record.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
---
title: "Records"
description: Learn about the record modifier for class and struct types in C#. Records provide standard support for value based equality on instances of record types.
ms.date: 01/14/2026
ms.date: 06/05/2026
f1_keywords:
- "record_CSharpKeyword"
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 Expand Up @@ -225,14 +228,14 @@ The result of a `with` expression has the same run-time type as the expression's

### `PrintMembers` formatting in derived records

The synthesized `PrintMembers` method of a derived record type calls the base implementation. The result is that all public properties and fields of both derived and base types are included in the `ToString` output, as shown in the following example:
The synthesized `PrintMembers` method of a derived record type calls the base implementation. The result is that the `ToString` output includes all public properties and fields of both derived and base types, as shown in the following example:

:::code language="csharp" source="snippets/shared/RecordType.cs" id="ToStringInheritance":::

You can provide your own implementation of the `PrintMembers` method. If you do that, use the following signature:
You can provide your own implementation of the `PrintMembers` method. If you do, use the following signature:

* For a `sealed` record that derives from `object` (doesn't declare a base record): `private bool PrintMembers(StringBuilder builder)`;
* For a `sealed` record that derives from another record (note that the enclosing type is `sealed`, so the method is effectively `sealed`): `protected override bool PrintMembers(StringBuilder builder)`;
* For a `sealed` record that derives from another record (the enclosing type is `sealed`, so the method is effectively `sealed`): `protected override bool PrintMembers(StringBuilder builder)`;
* For a record that isn't `sealed` and derives from object: `protected virtual bool PrintMembers(StringBuilder builder);`
* For a record that isn't `sealed` and derives from another record: `protected override bool PrintMembers(StringBuilder builder);`

Expand Down
3 changes: 2 additions & 1 deletion docs/csharp/language-reference/keywords/abstract.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description: "abstract - C# Reference"
title: "abstract keyword"
ms.date: 01/21/2026
ms.date: 06/06/2026
f1_keywords:
- "abstract"
- "abstract_CSharpKeyword"
Expand Down 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)
94 changes: 94 additions & 0 deletions docs/csharp/language-reference/keywords/closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
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*. You can only derive a direct subtype from a closed class 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.

```csharp
// Assembly 1
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;

// Assembly 2
public record class Paused : JobStatus; // Error: 'JobStatus' 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 `Failed` in the previous example is a plain record, another assembly can derive from it:

```csharp
// Assembly 2
public record class RetryableFailed(string Error, int Attempts) : Failed(Error); // OK: 'Failed' isn't sealed or closed
```

If you want to prevent derivation from `Failed` as well, declare it as `sealed` or `closed`.

[!INCLUDE[csharp-version-note](../includes/initial-version.md)]

> [!NOTE]
> `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.

## 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.
- You must declare a direct subtype of a closed class 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 `Tree<int>`), as opposed to an *open type* like `Tree<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.

```csharp
public closed record class Tree<T>;

public record class Leaf<T>(T Value) : Tree<T>; // OK: 'T' appears in the base class
public record class Branch<T>(Tree<T> Left, Tree<T> Right) : Tree<T>; // OK: 'T' appears in the base class
public record class Constant<U>(U Value) : Tree<int> { } // Error: 'U' isn't used in the base class
```

## 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 `JobStatus?` 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.

## 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)
5 changes: 3 additions & 2 deletions docs/csharp/language-reference/keywords/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description: "C# Keywords: Find the reference material for the predefined keywords and contextual keywords defined in the C# language."
title: "C# Keywords and contextual keywords"
ms.date: 01/21/2026
ms.date: 06/05/2026
f1_keywords:
- "cs.keywords"
helpviewer_keywords:
Expand Down 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
3 changes: 2 additions & 1 deletion docs/csharp/language-reference/keywords/sealed.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
description: "sealed modifier - C# Reference"
title: "sealed modifier"
ms.date: 01/22/2026
ms.date: 06/05/2026
f1_keywords:
- "sealed"
- "sealed_CSharpKeyword"
Expand Down 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,48 @@
namespace LanguageKeywords.ClosedHierarchies;

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

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_wrddwnnn_wpftmp.csproj]
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;

//<GenericRule>
//</GenericRule>

public static class ClosedSwitchExamples
{
//<ExhaustiveSwitch>
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 warning: every direct descendant of 'JobStatus' is handled.
};
//</ExhaustiveSwitch>

//<NullableSwitch>
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}",
// No warning: every direct descendant of 'JobStatus' is handled, and null is handled.
};
//</NullableSwitch>

//<TypeParameterConstrained>
public static string DescribeJob<X>(X status) where X : JobStatus => 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 warning: 'X' is constrained to a closed type, so its direct descendants exhaust the switch.
};
//</TypeParameterConstrained>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// In preview 5 of C# 15, the runtime doesn't yet define ClosedAttribute,
// so projects that use the 'closed' modifier must declare it.
namespace System.Runtime.CompilerServices;

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