Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/csharp/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ items:
href: whats-new/version-update-considerations.md
- name: Tutorials
items:
- name: Explore union types
href: whats-new/tutorials/unions.md
- name: Explore closed hierarchies
href: whats-new/tutorials/closed-hierarchies.md
- name: Explore extension members
href: whats-new/tutorials/extension-members.md
- name: Explore compound assignment
Expand Down
148 changes: 148 additions & 0 deletions docs/csharp/whats-new/tutorials/closed-hierarchies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
title: Explore closed hierarchies in C#
description: "A closed hierarchy restricts a base type's direct subtypes to one assembly so the compiler can verify exhaustive matches. Learn to declare closed hierarchies, decide what to seal, and use them with generics."
author: billwagner
ms.author: wiwagn
ms.service: dotnet-csharp
ms.topic: tutorial
ms.date: 07/03/2026
ai-usage: ai-assisted
#customer intent: As a C# developer, I model a fixed set of related types so the compiler enforces that I handle every subtype.
---
# Tutorial: Explore C# closed hierarchies

A *closed hierarchy* restricts the direct subtypes of a base type to the assembly that declares it. Because the compiler knows the full set of subtypes, it can verify that a switch handles every one without a default arm. Closed hierarchies suit domains where the set of cases is stable and you want the compiler to flag every place that needs to change when you add a case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...it can verify that a switch handles every one without a default arm...

Should it be explicit with switch expression or switch expression pattern?


In this tutorial, you build the sensor model of a smart-home telemetry monitor. You declare a closed hierarchy of sensor types, match the sensors exhaustively, and decide which cases to seal and which to leave open for extension.

In this tutorial, you:

> [!div class="checklist"]
>
> * Declare a closed hierarchy and match its cases exhaustively.
> * Decide which subtypes to seal and which to leave open.
> * Extend an open case from another assembly.
> * Use a closed hierarchy with generics.
## Prerequisites

This tutorial uses preview language features. You need an SDK that supports closed hierarchies and a language version set to `preview`.

- The .NET SDK that includes the closed hierarchies preview feature. Download it from the [.NET download site](https://dotnet.microsoft.com/download/dotnet).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be .NET 11 Preview X? The user doesn't really know what to download from this statement.

- An editor such as Visual Studio or Visual Studio Code with the C# Dev Kit.
Comment on lines +31 to +32

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These bullets seem to be lacking an intro statement.


> [!IMPORTANT]
> Closed hierarchies are a preview feature. The syntax and behavior can change before the feature ships. Set `<LangVersion>preview</LangVersion>` in your project to enable it.
## Create the sample solution

You build a class library, `SmartHome.Core`, that holds the closed hierarchy, a second library, `SmartHome.Extensions`, that extends an open case, and a console app, `SmartHome.App`, that drives them.

1. Create the solution and projects:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tutorials should be explicit in steps. Here the first step should be to open a terminal and run the following dotnet commands from a folder previously created to store the code of the tutorial.


```dotnetcli
dotnet new sln -n TelemetryMonitor
dotnet new classlib -n SmartHome.Core
dotnet new classlib -n SmartHome.Extensions
dotnet new console -n SmartHome.App
dotnet sln add SmartHome.Core SmartHome.Extensions SmartHome.App
dotnet add SmartHome.Extensions reference SmartHome.Core
dotnet add SmartHome.App reference SmartHome.Core SmartHome.Extensions
```

1. In each project file, set the language version to `preview`:

```xml
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
```
Comment on lines +53 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They could probably use --langVersion preview on all of the dotnet new commands in the previous step. If so, I would keep this here for reference, as an optional step. Someone may follow the tutorial but hate creating the new projects via terminal and prefer to use an IDE. If so, they'll need to know how to set the preview version.


## Declare a closed hierarchy

Start with the sensor model. The monitor supports a fixed set of sensor kinds, so you model them as a closed hierarchy in a single assembly.

1. In `SmartHome.Core`, add a file named `Sensors.cs` and declare the `Sensor` base type with the `closed` modifier and its three derived types:

:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="ClosedSensor":::

1. In the same file, add a method that matches every sensor with a switch:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1. In the same file, add a method that matches every sensor with a switch:
1. In the same file, add a method that matches every sensor with a switch expression:


:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="SensorExhaustive":::

1. Build the project. In earlier previews, you need to add a polyfill for the `Closed` attribute:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the reader is supposed to do. Are they supposed to add this? Only the latest preview is supported, so we should code directly for that and not include information that can confuse the reader.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "earlier previews" clear to readers?


:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs":::

The `closed` modifier restricts the direct subtypes of `Sensor` to the declaring assembly, and a `closed` type is implicitly abstract, so you can't instantiate it directly. Because the compiler knows the complete set of subtypes, the switch needs no default arm. If you add a new sensor type later, the compiler reports that this switch no longer covers every case. That feedback is the central benefit of a closed hierarchy. The compiler points you to every match that needs to change. The `Temperature` and `Humidity` cases are sealed because their shape is final, while `Contact` stays open as an extension point that the next section covers. For more information, see the [`closed` modifier](../../language-reference/keywords/closed.md) and [Closed hierarchy patterns](../../language-reference/operators/patterns.md#closed-hierarchy-patterns).

## Decide what to seal

A subtype of a closed type isn't itself closed unless you declare it so. For each subtype, you have three choices:

- Mark it `closed` to continue the hierarchy: further subtypes are allowed, but only in the same assembly.
- Mark it `sealed` to end the hierarchy: no further subtypes are allowed anywhere.
- Leave it unmarked to make it open: other assemblies can derive from it, and those derived types still match the original case.

You left `Contact` unmarked for that reason, so now you extend it from another assembly.

1. In `SmartHome.Extensions`, add a file named `DoorContact.cs` that derives from the open `Contact` case:

:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs" id="DoorContact":::

1. In `SmartHome.App`, add code that matches a `DoorContact` through the existing `Sensor` switch:

:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="DoorContactConsume":::

A `DoorContact` can't derive from `Sensor` directly, because it's closed to other assemblies. `DoorContact` extends the open `Contact` leaf instead. A `DoorContact` still matches the `Contact` case, so the exhaustive switch over `Sensor` stays correct without any change. When you choose what to seal, weigh the trade-off: seal a case to lock its shape and keep the hierarchy fully known, or leave a case open to allow extension at the cost of a less precise match, because the switch sees the open base case rather than the derived type. For more information, see the [`closed` modifier](../../language-reference/keywords/closed.md).

## Use a closed hierarchy with generics

A closed hierarchy can be generic. A report from the monitor is either a single value or a group of nested reports, so model it as a generic closed hierarchy.

1. In `SmartHome.Core`, add a file named `Report.cs` and declare the closed `Report<T>` base with its two cases. A derived type can't introduce a type parameter that the base type doesn't have, but it can supply fixed arguments for one or more of the base type's type parameters:

:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Report.cs" id="ClosedReport":::

1. Add a recursive method that accumulates the report by switching over its cases:

:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Report.cs" id="ReportFold":::

The switch over `Single<T>` and `Group<T>` is exhaustive because `Report<T>` is closed, so the recursive accumulator needs no default arm. For more information, see [Closed hierarchy patterns](../../language-reference/operators/patterns.md#closed-hierarchy-patterns).

## Run the sample

The console app builds each sensor and report, then prints them through the exhaustive switches:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be more expressive. It should tell the reader what file to open, to paste in the code, then to run it.


:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="ClosedMain":::

Run the app:

```dotnetcli
dotnet run --project SmartHome.App
```

The sensors and report print through their exhaustive switches:

```output
Sensor: 21.4°C
Sensor: 55% RH
Sensor: open
Sensor: closed
Report leaves: 3
```
Comment thread
BillWagner marked this conversation as resolved.

## Summary

You built the sensor model of a smart-home telemetry monitor and, in the process, worked through closed-hierarchy scenarios. You:

- Declared a `closed` `Sensor` base type and matched its subtypes with an exhaustive switch that needs no default arm.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this better?

Suggested change
- Declared a `closed` `Sensor` base type and matched its subtypes with an exhaustive switch that needs no default arm.
- Declared a `closed Sensor` base type and matched its subtypes with an exhaustive switch that needs no default arm.

- Weighed the three choices for each subtype: `closed` to continue the hierarchy in the same assembly, `sealed` to end it, or unmarked to leave an extension point.
- Extended the open `Contact` case from a separate assembly with `DoorContact`, and confirmed it still matches the `Contact` case in the existing switch.
- Declared a generic closed hierarchy, `Report<T>`, and folded it with a recursive exhaustive switch.

## Related content

- [Union types tutorial](unions.md)
- [Pattern matching overview](../../fundamentals/functional/pattern-matching.md)
- [What's new in C# 15](../csharp-15.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using SmartHome.Core;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This folder structure is violating the snippet design we have. If it's shared by multiple articles, it should be in a shared folder. For example, move the entire telementry-monitor folder:

‎docs/csharp/whats-new/tutorials/snippets/shared/telemetry-monitor/

using SmartHome.Extensions;

UnionMain();
ClosedMain();

void UnionMain()
{
// <UnionMain>
// Union declarations: a Reading holds a double, bool, or string.
Reading[] readings = [23.5, true, "offline"];
foreach (Reading reading in readings)
{
Console.WriteLine($"Reading: {ReadingReporter.Render(reading)}");
}
Console.WriteLine($"Reading: {ReadingReporter.Render(default)}");

// Migrate from a conventional result type to a union.
Result<double> ok = 42.0;
Result<double> bad = new TimeoutException("sensor timed out");
Console.WriteLine(ResultReporter.Describe(ok));
Console.WriteLine(ResultReporter.Describe(bad));

// Generic union reused across numeric widths.
Sample<byte> raw = (byte)200;
Sample<short> railed = new Saturated(High: true);
Console.WriteLine($"Sample: {SampleReporter.Normalize(raw, byte.MaxValue):P0} (in range: {raw.InRange})");
Console.WriteLine($"Sample: {SampleReporter.Normalize(railed, short.MaxValue):P0} (in range: {railed.InRange})");

// Custom non-boxing union.
Console.WriteLine(QuantityReporter.Describe(new Quantity(3)));
Console.WriteLine(QuantityReporter.Describe(new Quantity(2.5)));
// </UnionMain>
}

void ClosedMain()
{
// <ClosedMain>
// Closed hierarchy with an exhaustive switch.
Sensor[] sensors =
[
new Temperature(21.4),
new Humidity(55),
new Contact(Open: true),
];
foreach (Sensor sensor in sensors)
{
Console.WriteLine($"Sensor: {SensorReporter.Describe(sensor)}");
}

// <DoorContactConsume>
// A DoorContact from another assembly still matches the Contact case.
Sensor frontDoor = new DoorContact(Open: false, Door: "Front");
Console.WriteLine($"Sensor: {SensorReporter.Describe(frontDoor)}");
// </DoorContactConsume>

// Generic closed hierarchy.
Report<string> report = new Group<string>(
new Single<string>("Kitchen"),
new Group<string>(new Single<string>("Garage"), new Single<string>("Attic")));
Console.WriteLine($"Report leaves: {ReportReporter.Count(report)}");
// </ClosedMain>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\SmartHome.Core\SmartHome.Core.csproj" />
<ProjectReference Include="..\SmartHome.Extensions\SmartHome.Extensions.csproj" />
</ItemGroup>

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Runtime.CompilerServices;

namespace SmartHome.Core;

// <QuantityUnion>
// A custom union type. The 'union' shorthand boxes value-type cases, so a
// performance-sensitive type can implement the union pattern by hand to store
// the value without boxing. The [Union] attribute opts the struct into union
// behaviors; the non-boxing access members (HasValue and TryGetValue) let the
// compiler match each case without boxing.
[Union]
public readonly struct Quantity : IUnion
{
private readonly double _value;
private readonly bool _isCount;

public Quantity(int count) => (_value, _isCount) = (count, true);
public Quantity(double measure) => (_value, _isCount) = (measure, false);

public object? Value => _isCount ? (int)_value : _value;

public bool HasValue => true;
public bool TryGetValue(out int value)
{
value = (int)_value;
return _isCount;
}
public bool TryGetValue(out double value)
{
value = _value;
return !_isCount;
}
}
// </QuantityUnion>

public static class QuantityReporter
{
// <QuantityConsume>
public static string Describe(Quantity quantity) => quantity switch
{
int count => $"{count} items",
double measure => $"{measure:F2} units",
};
// </QuantityConsume>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace SmartHome.Core;

// <ReadingUnion>
// A union declaration is shorthand for a struct that holds one of the listed
// case types. The 'string?' case makes the contents nullable.
public union Reading(double, bool, string?);
// </ReadingUnion>

public static class ReadingReporter
{
// <ReadingConsume>
public static string Render(Reading reading) => reading switch
{
// Each type pattern applies to the union's contents, not to the Reading value.
double measure => $"{measure:F1}",
bool isOn => isOn ? "on" : "off",
string text => text,
// The contents can be null because 'string?' is a nullable case type.
null => "no reading",
};
// </ReadingConsume>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace SmartHome.Core;

// <ClosedReport>
// A generic closed hierarchy. Every type parameter of a derived type must appear
// in the base type, so a single derived construction exhausts each Report<T>.
public closed record class Report<T>;
public sealed record class Single<T>(T Value) : Report<T>;
public sealed record class Group<T>(Report<T> Left, Report<T> Right) : Report<T>;
// </ClosedReport>

public static class ReportReporter
{
// <ReportFold>
public static int Count<T>(Report<T> report) => report switch
{
Single<T> => 1,
Group<T> group => Count(group.Left) + Count(group.Right),
// Exhaustive: Report<T> is closed and both subtypes are handled.
};
// </ReportFold>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace SmartHome.Core;

// <ResultBefore>
// A conventional result type. Callers must remember to check 'IsSuccess' before
// they read 'Value', and nothing stops them from reading the wrong field.
public sealed class QueryResult<T>
{
private QueryResult(bool isSuccess, T? value, Exception? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}

public bool IsSuccess { get; }
public T? Value { get; }
public Exception? Error { get; }

public static QueryResult<T> Success(T value) => new(true, value, null);
public static QueryResult<T> Failure(Exception error) => new(false, default, error);
}
// </ResultBefore>

// <ResultUnion>
// The same idea as a union declaration. A Result<T> holds either a T or an Exception.
public union Result<T>(T, Exception);
// </ResultUnion>

public static class ResultReporter
{
// <ResultConsume>
public static string Describe<T>(Result<T> result) => result switch
{
T value => $"Success: {value}",
Exception error => $"Failure: {error.Message}",
null => "Failure: no value",
};
// </ResultConsume>
}
Loading
Loading