-
Notifications
You must be signed in to change notification settings - Fork 6.1k
Add exploration tutorial for unions and closed hierarchies #54627
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
BillWagner
wants to merge
7
commits into
dotnet:main
Choose a base branch
from
BillWagner:explore-unions-closed-hierarchies
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
2340b32
First draft of these tutorials
BillWagner d79e6e2
Use a better generic sample
BillWagner c6dc1e0
Review articles.
BillWagner 731394d
Potential fix for pull request finding
BillWagner aa05421
Update sample and code.
BillWagner e61e598
Fix preview build errors
BillWagner 63b5898
Fix snippet name
BillWagner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| In this tutorial, you build the sensor model of a smart-home telemetry monitor. You declare a closed hierarchy of sensor types, you match the sensors exhaustively, and you 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). | ||
| - An editor such as Visual Studio or Visual Studio Code with the C# Dev Kit. | ||
|
|
||
| > [!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: | ||
|
|
||
| ```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> | ||
| ``` | ||
|
|
||
| ## 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: | ||
|
|
||
| :::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: | ||
|
|
||
| :::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: | ||
|
|
||
| :::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 | ||
| ``` | ||
|
|
||
| ## 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. | ||
| - 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) | ||
63 changes: 63 additions & 0 deletions
63
docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| using SmartHome.Core; | ||
| 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> | ||
| } |
16 changes: 16 additions & 0 deletions
16
.../csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/SmartHome.App.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
4 changes: 4 additions & 0 deletions
4
docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { } |
45 changes: 45 additions & 0 deletions
45
docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
22 changes: 22 additions & 0 deletions
22
docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
21 changes: 21 additions & 0 deletions
21
docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Report.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
39 changes: 39 additions & 0 deletions
39
docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Result.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.