From 2340b32c4cc6beabfeba9ee37da75ab884768fb1 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 26 Jun 2026 15:28:14 -0400 Subject: [PATCH 1/7] First draft of these tutorials --- docs/csharp/toc.yml | 4 + .../whats-new/tutorials/closed-hierarchies.md | 127 ++++++++++++++++ .../SmartHome.App/Program.cs | 50 +++++++ .../SmartHome.App/SmartHome.App.csproj | 16 ++ .../SmartHome.Core/Quantity.cs | 45 ++++++ .../SmartHome.Core/Readings.cs | 36 +++++ .../SmartHome.Core/Report.cs | 21 +++ .../SmartHome.Core/Result.cs | 39 +++++ .../SmartHome.Core/Sensors.cs | 27 ++++ .../SmartHome.Core/SmartHome.Core.csproj | 10 ++ .../SmartHome.Core/UnionSupport.cs | 21 +++ .../SmartHome.Extensions/DoorContact.cs | 11 ++ .../SmartHome.Extensions.csproj | 14 ++ docs/csharp/whats-new/tutorials/unions.md | 139 ++++++++++++++++++ 14 files changed, 560 insertions(+) create mode 100644 docs/csharp/whats-new/tutorials/closed-hierarchies.md create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/SmartHome.App.csproj create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Report.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Result.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sensors.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/SmartHome.Core.csproj create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/UnionSupport.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/SmartHome.Extensions.csproj create mode 100644 docs/csharp/whats-new/tutorials/unions.md diff --git a/docs/csharp/toc.yml b/docs/csharp/toc.yml index 32ef47e296010..92b994442b625 100644 --- a/docs/csharp/toc.yml +++ b/docs/csharp/toc.yml @@ -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 diff --git a/docs/csharp/whats-new/tutorials/closed-hierarchies.md b/docs/csharp/whats-new/tutorials/closed-hierarchies.md new file mode 100644 index 0000000000000..f61980ad85eb5 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/closed-hierarchies.md @@ -0,0 +1,127 @@ +--- +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: 02/16/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 `preview` 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 + + preview + + ``` + +## Declare a closed hierarchy + +The `closed` modifier on a base type restricts its direct subtypes to the declaring assembly. A `closed` type is implicitly abstract, so you can't instantiate it directly. The following declaration defines a `Sensor` base with three cases: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="ClosedSensor"::: + +The `Temperature` and `Humidity` cases are sealed because their shape is final. The `Contact` case stays open as an extension point, which the next section covers. + +## Match the hierarchy exhaustively + +Because `Sensor` is closed, the compiler knows the complete set of subtypes. A switch that handles every case needs no default arm: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="SensorExhaustive"::: + +If you add a new sensor type to the hierarchy, 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. + +## Decide what to seal + +A subtype of a closed type isn't itself closed unless you declare it so. A sealed subtype can't be extended at all. A subtype that's neither sealed nor closed becomes an *escape hatch*: other assemblies can derive from it, and those derived types still match the original case. + +The `Contact` case is left open for that reason. A second assembly can specialize it without breaking the closed hierarchy. The following type lives in `SmartHome.Extensions` and derives from `Contact`: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs" id="DoorContact"::: + +A `DoorContact` can't derive from `Sensor` directly, because the hierarchy is closed to other assemblies. It extends the open `Contact` leaf instead. A `DoorContact` still matches the `Contact` case, so the exhaustive switch over `Sensor` stays correct without any change: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="DoorContactConsume"::: + +When you choose what to seal, weigh the trade-off. Seal a case to lock its shape and keep the hierarchy fully known. Leave a case open to allow extension at the cost of a less precise match: the switch sees the open base case, not the derived type. + +## Use a closed hierarchy with generics + +A closed hierarchy can be generic. Each type parameter of a derived type must appear in the base type, so a single derived construction exhausts each constructed base type. The following hierarchy models a report that's either a single value or a pair of nested reports: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Report.cs" id="ClosedReport"::: + +A recursive method folds the report. The switch over `Single` and `Group` is exhaustive because `Report` is closed: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Report.cs" id="ReportFold"::: + +## 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="ProgramMain"::: + +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 +``` + +## Related content + +- [Union types tutorial](unions.md) +- [Pattern matching overview](../../fundamentals/functional/pattern-matching.md) +- [What's new in C# 15](../csharp-15.md) diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs new file mode 100644 index 0000000000000..37fd0eac8528f --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs @@ -0,0 +1,50 @@ +using SmartHome.Core; +using SmartHome.Extensions; + +// +// 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)}"); + +// Generic union declaration with a member. +OneOrMore rooms = new(["Kitchen", "Garage"]); +Console.WriteLine($"Rooms: {string.Join(", ", rooms.AsEnumerable())}"); + +// Migrate from a conventional result type to a union. +Result ok = 42.0; +Result bad = new TimeoutException("sensor timed out"); +Console.WriteLine(ResultReporter.Describe(ok)); +Console.WriteLine(ResultReporter.Describe(bad)); + +// Custom non-boxing union. +Console.WriteLine(QuantityReporter.Describe(new Quantity(3))); +Console.WriteLine(QuantityReporter.Describe(new Quantity(2.5))); + +// 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)}"); +} +// + +// +// A DoorContact from another assembly still matches the Contact case. +Sensor frontDoor = new DoorContact(Open: false, Door: "Front"); +Console.WriteLine($"Sensor: {SensorReporter.Describe(frontDoor)}"); +// + +// Generic closed hierarchy. +Report report = new Group( + new Single("Kitchen"), + new Group(new Single("Garage"), new Single("Attic"))); +Console.WriteLine($"Report leaves: {ReportReporter.Count(report)}"); diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/SmartHome.App.csproj b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/SmartHome.App.csproj new file mode 100644 index 0000000000000..6b21776a405c3 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/SmartHome.App.csproj @@ -0,0 +1,16 @@ + + + + + + + + + Exe + net10.0 + enable + enable + preview + + + diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs new file mode 100644 index 0000000000000..b04441a8600f1 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs @@ -0,0 +1,45 @@ +using System.Runtime.CompilerServices; + +namespace SmartHome.Core; + +// +// 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 +{ + 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; + } +} +// + +public static class QuantityReporter +{ + // + public static string Describe(Quantity quantity) => quantity switch + { + int count => $"{count} items", + double measure => $"{measure:F2} units", + }; + // +} diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs new file mode 100644 index 0000000000000..2f5a60aafdf36 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs @@ -0,0 +1,36 @@ +namespace SmartHome.Core; + +// +// 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?); +// + +public static class ReadingReporter +{ + // + 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", + }; + // +} + +// +// A generic union declaration can include members. This one normalizes a value +// that's either a single item or a sequence of items. +public union OneOrMore(T, IEnumerable) +{ + public IEnumerable AsEnumerable() => this switch + { + IEnumerable many => many, + T one => [one], + null => [], + }; +} +// diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Report.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Report.cs new file mode 100644 index 0000000000000..36366bb61acd8 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Report.cs @@ -0,0 +1,21 @@ +namespace SmartHome.Core; + +// +// 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. +public closed record class Report; +public sealed record class Single(T Value) : Report; +public sealed record class Group(Report Left, Report Right) : Report; +// + +public static class ReportReporter +{ + // + public static int Count(Report report) => report switch + { + Single => 1, + Group group => Count(group.Left) + Count(group.Right), + // Exhaustive: Report is closed and both subtypes are handled. + }; + // +} diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Result.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Result.cs new file mode 100644 index 0000000000000..8f2ac6c5fcbaa --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Result.cs @@ -0,0 +1,39 @@ +namespace SmartHome.Core; + +// +// 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 +{ + 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 Success(T value) => new(true, value, null); + public static QueryResult Failure(Exception error) => new(false, default, error); +} +// + +// +// The same idea as a union declaration. A Result holds either a T or an Exception. +public union Result(T, Exception); +// + +public static class ResultReporter +{ + // + public static string Describe(Result result) => result switch + { + T value => $"Success: {value}", + Exception error => $"Failure: {error.Message}", + null => "Failure: no value", + }; + // +} diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sensors.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sensors.cs new file mode 100644 index 0000000000000..a98a2dbf7cb4d --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sensors.cs @@ -0,0 +1,27 @@ +namespace SmartHome.Core; + +// +// A closed class restricts its direct subtypes to this assembly. A 'closed' +// class is implicitly abstract. +public closed record class Sensor; + +// Seal the cases whose shape is final. +public sealed record class Temperature(double Celsius) : Sensor; +public sealed record class Humidity(double Percent) : Sensor; + +// Leave a case unsealed as an "escape hatch" so other assemblies can specialize it. +public record class Contact(bool Open) : Sensor; +// + +public static class SensorReporter +{ + // + public static string Describe(Sensor sensor) => sensor switch + { + Temperature temperature => $"{temperature.Celsius:F1}°C", + Humidity humidity => $"{humidity.Percent:F0}% RH", + Contact contact => contact.Open ? "open" : "closed", + // No default arm is needed. Sensor is closed, so these cases are exhaustive. + }; + // +} diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/SmartHome.Core.csproj b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/SmartHome.Core.csproj new file mode 100644 index 0000000000000..39b1711893882 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/SmartHome.Core.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + preview + + + diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/UnionSupport.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/UnionSupport.cs new file mode 100644 index 0000000000000..f735dbc2c28eb --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/UnionSupport.cs @@ -0,0 +1,21 @@ +// TEMPORARY SHIM. +// +// The unions and closed-hierarchy features rely on these support types. A later +// .NET SDK ships them in the base class library, at which point this file should +// be deleted. They're defined here so the sample compiles on the preview SDK that +// recognizes the language syntax but doesn't yet include the types. +// +// This file is intentionally NOT referenced by any tutorial snippet. + +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] +public class UnionAttribute : Attribute; + +public interface IUnion +{ + object? Value { get; } +} + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class IsClosedTypeAttribute : Attribute; diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs new file mode 100644 index 0000000000000..05a5eba6b097d --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs @@ -0,0 +1,11 @@ +using SmartHome.Core; + +namespace SmartHome.Extensions; + +// +// This type lives in a different assembly than the closed Sensor hierarchy. +// It can't derive from Sensor directly, but it can extend the unsealed Contact +// leaf. A DoorContact still matches the 'Contact' case, so switches over Sensor +// stay exhaustive. +public sealed record class DoorContact(bool Open, string Door) : Contact(Open); +// diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/SmartHome.Extensions.csproj b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/SmartHome.Extensions.csproj new file mode 100644 index 0000000000000..d3227e65cc5e2 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Extensions/SmartHome.Extensions.csproj @@ -0,0 +1,14 @@ + + + + + + + + net10.0 + enable + enable + preview + + + diff --git a/docs/csharp/whats-new/tutorials/unions.md b/docs/csharp/whats-new/tutorials/unions.md new file mode 100644 index 0000000000000..1c265a14431ad --- /dev/null +++ b/docs/csharp/whats-new/tutorials/unions.md @@ -0,0 +1,139 @@ +--- +title: Explore union types in C# +description: "Union types let a single value hold one of a fixed set of types. Learn to declare union types, match their cases exhaustively, build custom unions, and use unions with generics." +author: billwagner +ms.author: wiwagn +ms.service: dotnet-csharp +ms.topic: tutorial +ms.date: 02/16/2026 +ai-usage: ai-assisted +#customer intent: As a C# developer, I model a value that can be one of several types so the compiler enforces that I handle every case. +--- +# Tutorial: Explore C# union types + +A *union type* holds a single value that's one of a fixed set of types. Unions are useful when a value is conceptually "one of these," such as a sensor reading that's a number, a switch state, or a status message. Because the set of cases is fixed, the compiler can verify that you handle every case when you read the value. + +In this tutorial, you build the readings layer of a smart-home telemetry monitor. You declare union types for sensor data, you replace a hand-rolled result type with a union, and you create a custom union for performance-sensitive code. + +In this tutorial, you: + +> [!div class="checklist"] +> +> * Declare union types and match their cases exhaustively. +> * Handle null contents in a union. +> * Add members and use generics with union declarations. +> * Build a custom union type that avoids boxing. + +## Prerequisites + +This tutorial uses preview language features. You need an SDK that supports union types and a language version set to `preview`. + +- The .NET SDK that includes the union types 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] +> Union types are a preview feature. The syntax and supporting types can change before the feature ships. Set `preview` in your project to enable it. + +## Create the sample solution + +You build a class library, `SmartHome.Core`, that holds the union types, and a console app, `SmartHome.App`, that exercises them. + +1. Create the solution and projects: + + ```dotnetcli + dotnet new sln -n TelemetryMonitor + dotnet new classlib -n SmartHome.Core + dotnet new console -n SmartHome.App + dotnet sln add SmartHome.Core SmartHome.App + dotnet add SmartHome.App reference SmartHome.Core + ``` + +1. In each project file, set the language version to `preview`: + + ```xml + + preview + + ``` + +## Declare a union type + +A *union declaration* lists the case types in parentheses. The following declaration says a `Reading` holds a `double`, a `bool`, or a `string`: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingUnion"::: + +The `string?` case is nullable, so the contents of a `Reading` can be null. The compiler treats null as a distinct case you must handle. + +To read a union, switch over the case types. Each type pattern applies to the union's *contents*, not to the `Reading` value: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingConsume"::: + +The switch is exhaustive. Because `string?` is nullable, the compiler requires the `null` arm. If you remove any arm, the compiler reports that a case isn't covered. That guarantee is the central benefit of a union: when you add a case type later, every switch that reads the union flags the missing arm. + +## Migrate a result type to a generic union + +Unions work well with generics. A common pattern is a result type that holds either a success value or an error. Many codebases model that with a class that exposes a success flag and two fields: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultBefore"::: + +Nothing stops a caller from reading `Value` on a failure or `Error` on a success. A generic union expresses the same intent and removes the invalid states. A `Result` holds either a `T` or an `Exception`: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultUnion"::: + +To read the result, switch over the case types. The compiler verifies that you handle the value, the error, and null: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultConsume"::: + +A value converts to the union implicitly, so you assign a `T` or an `Exception` directly to a `Result`. + +## Add members to a union + +A union declaration can include a body with members. The following generic union normalizes a value that's either a single item or a sequence. Its `AsEnumerable` method switches over `this` to return a uniform sequence: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="OneOrMore"::: + +The `null` arm handles the default value of the union. Because a union is a struct, an uninitialized `OneOrMore` has null contents, so the switch must cover that case. + +## Build a custom union that avoids boxing + +A union declaration lowers to a struct that stores its value in a single `object?` field, so a value-type case boxes when you store it. For performance-sensitive code, you can implement the union pattern by hand to store the value without boxing. Apply the `[Union]` attribute to the struct, and provide the access members the compiler uses to match each case: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityUnion"::: + +The `HasValue` property and the `TryGetValue` overloads let the compiler match each case without boxing. The struct stores the value in a `double` field and a discriminator, so neither the `int` case nor the `double` case allocates. + +You consume a custom union the same way you consume a union declaration. The switch over `int` and `double` is exhaustive, and no null arm is needed because neither case type is nullable: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityConsume"::: + +## Run the sample + +The console app drives each union. Replace the contents of `Program.cs`: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="ProgramMain"::: + +Run the app: + +```dotnetcli +dotnet run --project SmartHome.App +``` + +The readings, results, and quantities each print through their exhaustive switches: + +```output +Reading: 23.5 +Reading: on +Reading: offline +Reading: no reading +Rooms: Kitchen, Garage +Success: 42 +Failure: sensor timed out +3 items +2.50 units +``` + +## Related content + +- [Closed hierarchies tutorial](closed-hierarchies.md) +- [Pattern matching overview](../../fundamentals/functional/pattern-matching.md) +- [What's new in C# 15](../csharp-15.md) From d79e6e26574a0f826f2c3f8e4dc18573deb246da Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 26 Jun 2026 16:13:23 -0400 Subject: [PATCH 2/7] Use a better generic sample --- .../SmartHome.App/Program.cs | 10 +++--- .../SmartHome.Core/Readings.cs | 16 +-------- .../SmartHome.Core/Sample.cs | 34 +++++++++++++++++++ docs/csharp/whats-new/tutorials/unions.md | 19 +++++++---- 4 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sample.cs diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs index 37fd0eac8528f..42193d19d958d 100644 --- a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs @@ -10,16 +10,18 @@ } Console.WriteLine($"Reading: {ReadingReporter.Render(default)}"); -// Generic union declaration with a member. -OneOrMore rooms = new(["Kitchen", "Garage"]); -Console.WriteLine($"Rooms: {string.Join(", ", rooms.AsEnumerable())}"); - // Migrate from a conventional result type to a union. Result ok = 42.0; Result bad = new TimeoutException("sensor timed out"); Console.WriteLine(ResultReporter.Describe(ok)); Console.WriteLine(ResultReporter.Describe(bad)); +// Generic union reused across numeric widths. +Sample raw = (byte)200; +Sample 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))); diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs index 2f5a60aafdf36..3ac3e62ae6d2d 100644 --- a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs @@ -1,4 +1,4 @@ -namespace SmartHome.Core; +namespace SmartHome.Core; // // A union declaration is shorthand for a struct that holds one of the listed @@ -20,17 +20,3 @@ public static class ReadingReporter }; // } - -// -// A generic union declaration can include members. This one normalizes a value -// that's either a single item or a sequence of items. -public union OneOrMore(T, IEnumerable) -{ - public IEnumerable AsEnumerable() => this switch - { - IEnumerable many => many, - T one => [one], - null => [], - }; -} -// diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sample.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sample.cs new file mode 100644 index 0000000000000..a43ceb8795444 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Sample.cs @@ -0,0 +1,34 @@ +using System.Numerics; + +namespace SmartHome.Core; + +// +// A generic union, reusable across sensors of different numeric precision. A +// Sample holds either an in-range reading of type T or a Saturated marker +// for a sensor that railed. The 'struct, INumber' constraint keeps T a +// non-nullable numeric value type, so a switch over the cases needs no null arm. +public union Sample(T, Saturated) where T : struct, INumber +{ + // A union can declare members. This property patterns over 'this' to report + // whether the sample carries a usable reading. + public bool InRange => this is T; +} + +// A marker for a railed sensor, carrying which rail it hit. +public readonly record struct Saturated(bool High); +// + +public static class SampleReporter +{ + // + // Scale any numeric width to a fraction of full scale. The INumber + // constraint makes the same arithmetic work for byte, short, int, and more. + public static double Normalize(Sample sample, T fullScale) + where T : struct, INumber => sample switch + { + T value => double.CreateChecked(value) / double.CreateChecked(fullScale), + Saturated { High: true } => 1.0, + Saturated => 0.0, + }; + // +} diff --git a/docs/csharp/whats-new/tutorials/unions.md b/docs/csharp/whats-new/tutorials/unions.md index 1c265a14431ad..4a36b8b2f0796 100644 --- a/docs/csharp/whats-new/tutorials/unions.md +++ b/docs/csharp/whats-new/tutorials/unions.md @@ -86,13 +86,19 @@ To read the result, switch over the case types. The compiler verifies that you h A value converts to the union implicitly, so you assign a `T` or an `Exception` directly to a `Result`. -## Add members to a union +## Reuse a generic union across numeric types -A union declaration can include a body with members. The following generic union normalizes a value that's either a single item or a sequence. Its `AsEnumerable` method switches over `this` to return a uniform sequence: +A generic union pays off when you reuse it across several closed-over types. Sensors report at different resolutions: a contact sensor uses a `byte`, a position sensor uses a `short`, and a counter uses an `int`. A single generic union models a sample from any of them. A `Sample` holds either an in-range reading of type `T` or a `Saturated` marker for a sensor that railed: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="OneOrMore"::: +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleUnion"::: -The `null` arm handles the default value of the union. Because a union is a struct, an uninitialized `OneOrMore` has null contents, so the switch must cover that case. +The `where T : struct, INumber` constraint keeps `T` a non-nullable numeric value type. Because neither case type is nullable, a switch over the cases needs no null arm. A union declaration can also include a body, so `Sample` adds an `InRange` property that patterns over `this` to report whether the sample carries a usable reading. + +The constraint lets a single method process every numeric width. To scale any reading to a fraction of full scale, the method relies on arithmetic: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleConsume"::: + +Because `Saturated` is a value type and `T` is constrained to a value type, the switch over `T` and `Saturated` is exhaustive without a null arm. The same `Normalize` method works for a `Sample`, a `Sample`, or a `Sample`. ## Build a custom union that avoids boxing @@ -118,16 +124,17 @@ Run the app: dotnet run --project SmartHome.App ``` -The readings, results, and quantities each print through their exhaustive switches: +The readings, results, samples, and quantities each print through their exhaustive switches: ```output Reading: 23.5 Reading: on Reading: offline Reading: no reading -Rooms: Kitchen, Garage Success: 42 Failure: sensor timed out +Sample: 78% (in range: True) +Sample: 100% (in range: False) 3 items 2.50 units ``` From c6dc1e08b88e905b77897907b3a1e2450788230d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 2 Jul 2026 14:43:26 -0400 Subject: [PATCH 3/7] Review articles. --- .../whats-new/tutorials/closed-hierarchies.md | 55 +++++++++----- docs/csharp/whats-new/tutorials/unions.md | 71 ++++++++++++------- 2 files changed, 80 insertions(+), 46 deletions(-) diff --git a/docs/csharp/whats-new/tutorials/closed-hierarchies.md b/docs/csharp/whats-new/tutorials/closed-hierarchies.md index f61980ad85eb5..0b01bb80ccceb 100644 --- a/docs/csharp/whats-new/tutorials/closed-hierarchies.md +++ b/docs/csharp/whats-new/tutorials/closed-hierarchies.md @@ -5,7 +5,7 @@ author: billwagner ms.author: wiwagn ms.service: dotnet-csharp ms.topic: tutorial -ms.date: 02/16/2026 +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. --- @@ -60,43 +60,51 @@ You build a class library, `SmartHome.Core`, that holds the closed hierarchy, a ## Declare a closed hierarchy -The `closed` modifier on a base type restricts its direct subtypes to the declaring assembly. A `closed` type is implicitly abstract, so you can't instantiate it directly. The following declaration defines a `Sensor` base with three cases: +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. -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="ClosedSensor"::: +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: -The `Temperature` and `Humidity` cases are sealed because their shape is final. The `Contact` case stays open as an extension point, which the next section covers. + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="ClosedSensor"::: -## Match the hierarchy exhaustively +1. In the same file, add a method that matches every sensor with a switch: -Because `Sensor` is closed, the compiler knows the complete set of subtypes. A switch that handles every case needs no default arm: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="SensorExhaustive"::: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sensors.cs" id="SensorExhaustive"::: - -If you add a new sensor type to the hierarchy, 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 `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. A sealed subtype can't be extended at all. A subtype that's neither sealed nor closed becomes an *escape hatch*: other assemblies can derive from it, and those derived types still match the original case. +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. -The `Contact` case is left open for that reason. A second assembly can specialize it without breaking the closed hierarchy. The following type lives in `SmartHome.Extensions` and derives from `Contact`: +You left `Contact` unmarked for that reason, so now you extend it from another assembly. -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs" id="DoorContact"::: +1. In `SmartHome.Extensions`, add a file named `DoorContact.cs` that derives from the open `Contact` case: -A `DoorContact` can't derive from `Sensor` directly, because the hierarchy is closed to other assemblies. It extends the open `Contact` leaf instead. A `DoorContact` still matches the `Contact` case, so the exhaustive switch over `Sensor` stays correct without any change: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Extensions/DoorContact.cs" id="DoorContact"::: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="DoorContactConsume"::: +1. In `SmartHome.App`, add code that matches a `DoorContact` through the existing `Sensor` switch: -When you choose what to seal, weigh the trade-off. Seal a case to lock its shape and keep the hierarchy fully known. Leave a case open to allow extension at the cost of a less precise match: the switch sees the open base case, not the derived type. + :::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. Each type parameter of a derived type must appear in the base type, so a single derived construction exhausts each constructed base type. The following hierarchy models a report that's either a single value or a pair of nested reports: +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` 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"::: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Report.cs" id="ClosedReport"::: -A recursive method folds the report. The switch over `Single` and `Group` is exhaustive because `Report` is closed: +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"::: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Report.cs" id="ReportFold"::: + +The switch over `Single` and `Group` is exhaustive because `Report` 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 @@ -120,6 +128,15 @@ 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`, and folded it with a recursive exhaustive switch. + ## Related content - [Union types tutorial](unions.md) diff --git a/docs/csharp/whats-new/tutorials/unions.md b/docs/csharp/whats-new/tutorials/unions.md index 4a36b8b2f0796..07869d90b8af9 100644 --- a/docs/csharp/whats-new/tutorials/unions.md +++ b/docs/csharp/whats-new/tutorials/unions.md @@ -5,13 +5,13 @@ author: billwagner ms.author: wiwagn ms.service: dotnet-csharp ms.topic: tutorial -ms.date: 02/16/2026 +ms.date: 07/02s/2026 ai-usage: ai-assisted #customer intent: As a C# developer, I model a value that can be one of several types so the compiler enforces that I handle every case. --- # Tutorial: Explore C# union types -A *union type* holds a single value that's one of a fixed set of types. Unions are useful when a value is conceptually "one of these," such as a sensor reading that's a number, a switch state, or a status message. Because the set of cases is fixed, the compiler can verify that you handle every case when you read the value. +A *union type* holds a single value that's one of a fixed set of types. Those types typically aren't related by inheritance, so a union lets you group unrelated types without forcing them into a common base class or interface. Unions are useful when a value is conceptually "one of these," such as a sensor reading that's a number, a switch state, or a status message. Because the set of cases is fixed, the compiler can verify that you handle every case when you read the value. In this tutorial, you build the readings layer of a smart-home telemetry monitor. You declare union types for sensor data, you replace a hand-rolled result type with a union, and you create a custom union for performance-sensitive code. @@ -28,7 +28,7 @@ In this tutorial, you: This tutorial uses preview language features. You need an SDK that supports union types and a language version set to `preview`. -- The .NET SDK that includes the union types preview feature. Download it from the [.NET download site](https://dotnet.microsoft.com/download/dotnet). +- The .NET 11 SDK that includes the union types 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] @@ -58,59 +58,66 @@ You build a class library, `SmartHome.Core`, that holds the union types, and a c ## Declare a union type -A *union declaration* lists the case types in parentheses. The following declaration says a `Reading` holds a `double`, a `bool`, or a `string`: +Start with the raw sensor data. A reading arrives as a number, a switch state, or a status message, so you model it as a union of those three types. -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingUnion"::: +1. In `SmartHome.Core`, add a file named `Readings.cs` and declare the `Reading` union. A *union declaration* lists the case types in parentheses: -The `string?` case is nullable, so the contents of a `Reading` can be null. The compiler treats null as a distinct case you must handle. + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingUnion"::: -To read a union, switch over the case types. Each type pattern applies to the union's *contents*, not to the `Reading` value: +1. In the same file, add a method that reads a `Reading` by switching over its case types: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingConsume"::: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingConsume"::: -The switch is exhaustive. Because `string?` is nullable, the compiler requires the `null` arm. If you remove any arm, the compiler reports that a case isn't covered. That guarantee is the central benefit of a union: when you add a case type later, every switch that reads the union flags the missing arm. +Each type pattern applies to the union's *contents*, not to the `Reading` instance, because most patterns unwrap the union. The `string?` case is nullable, so the compiler treats null as a distinct case and requires the `null` arm. The switch is exhaustive: if you remove any arm, the compiler reports that a case isn't covered. That guarantee is one benefit of a union. When you add a case type later, every switch that reads the union flags the missing arm. For more information, see [Union exhaustiveness](../../language-reference/builtin-types/union.md#union-exhaustiveness) and [Union patterns](../../language-reference/operators/patterns.md#union-patterns). ## Migrate a result type to a generic union -Unions work well with generics. A common pattern is a result type that holds either a success value or an error. Many codebases model that with a class that exposes a success flag and two fields: +When a sensor read can fail, you need a type that carries either a value or an error. Many codebases model that type with a class that exposes a success flag and two fields. Replace that class with a generic union. -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultBefore"::: +1. In `SmartHome.Core`, add a file named `Result.cs` with the hand-rolled result class that the migration replaces: -Nothing stops a caller from reading `Value` on a failure or `Error` on a success. A generic union expresses the same intent and removes the invalid states. A `Result` holds either a `T` or an `Exception`: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultBefore"::: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultUnion"::: +1. Replace that class with a generic union. A `Result` holds either a `T` or an `Exception`: -To read the result, switch over the case types. The compiler verifies that you handle the value, the error, and null: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultUnion"::: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultConsume"::: +1. Add a method that reads the result by switching over its case types: -A value converts to the union implicitly, so you assign a `T` or an `Exception` directly to a `Result`. + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultConsume"::: + +The original class lets a caller incorrectly read `Value` on a failure or `Error` on a success. The union removes those invalid states: a `Result` is either a `T` that holds the successful result or an `Exception` that explains why the operation failed. Each case converts to the union implicitly, so you assign an instance of `T` or an instance of `Exception` directly to a `Result`. When you read the result, a switch verifies that you handle the success value, the failure, and null. For more information, see [Union declarations](../../language-reference/builtin-types/union.md#union-declarations) and [Union conversions](../../language-reference/builtin-types/union.md#union-conversions). ## Reuse a generic union across numeric types -A generic union pays off when you reuse it across several closed-over types. Sensors report at different resolutions: a contact sensor uses a `byte`, a position sensor uses a `short`, and a counter uses an `int`. A single generic union models a sample from any of them. A `Sample` holds either an in-range reading of type `T` or a `Saturated` marker for a sensor that railed: +Sensors report at different resolutions: a contact sensor uses a `byte`, a position sensor uses a `short`, and a counter uses an `int`. Rather than write a union per type, write one generic union and instantiate it with each numeric type. -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleUnion"::: +1. In `SmartHome.Core`, add a file named `Sample.cs` and declare the `Sample` union. It holds either an in-range reading of type `T` or a `Saturated` marker for a sensor that railed. Give the union a body that adds an `InRange` property: -The `where T : struct, INumber` constraint keeps `T` a non-nullable numeric value type. Because neither case type is nullable, a switch over the cases needs no null arm. A union declaration can also include a body, so `Sample` adds an `InRange` property that patterns over `this` to report whether the sample carries a usable reading. + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleUnion"::: -The constraint lets a single method process every numeric width. To scale any reading to a fraction of full scale, the method relies on arithmetic: +1. Add a `Normalize` method that scales any reading to a fraction of full scale: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleConsume"::: + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleConsume"::: -Because `Saturated` is a value type and `T` is constrained to a value type, the switch over `T` and `Saturated` is exhaustive without a null arm. The same `Normalize` method works for a `Sample`, a `Sample`, or a `Sample`. +The `where T : struct, INumber` constraint keeps `T` a non-nullable numeric value type and lets a single method process every numeric width through arithmetic. The same `Normalize` method works for a `Sample`, a `Sample`, or a `Sample`. Because `Saturated` is a value type and `T` is constrained to a value type, neither case is nullable, so the switch is exhaustive without a null arm. The `InRange` property shows that a union declaration can carry its own members and pattern over `this`. For more information, see [Union declarations](../../language-reference/builtin-types/union.md#union-declarations). ## Build a custom union that avoids boxing -A union declaration lowers to a struct that stores its value in a single `object?` field, so a value-type case boxes when you store it. For performance-sensitive code, you can implement the union pattern by hand to store the value without boxing. Apply the `[Union]` attribute to the struct, and provide the access members the compiler uses to match each case: +A union declaration is a struct that stores its value in a single `object?` field, so a value-type case boxes when you store it. For performance-sensitive code, implement the union pattern by hand to store the value without boxing. + +1. In `SmartHome.Core`, add a file named `Quantity.cs`. Apply the `[Union]` attribute to a struct, and provide the access members the compiler uses to match each case: + + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityUnion"::: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityUnion"::: +1. Add a method that consumes the custom union the same way you consume a union declaration: -The `HasValue` property and the `TryGetValue` overloads let the compiler match each case without boxing. The struct stores the value in a `double` field and a discriminator, so neither the `int` case nor the `double` case allocates. + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityConsume"::: -You consume a custom union the same way you consume a union declaration. The switch over `int` and `double` is exhaustive, and no null arm is needed because neither case type is nullable: +The `HasValue` property and the `TryGetValue` overloads let the compiler match each case without boxing. The struct stores the value in a `double` field and a discriminator, so neither the `int` case nor the `double` case allocates. The switch over `int` and `double` is exhaustive, and no null arm is needed because neither case type is nullable. For more information, see [Custom union types](../../language-reference/builtin-types/union.md#custom-union-types). -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityConsume"::: +> [!NOTE] +> Another scenario for a custom union is to add language support to an existing type that models a union. If your app has union-like types you created before C# had `union` types, apply the `Union` attribute and implement the `IUnion` interface on those types. The compiler then gives your custom type the same language support as a union declaration. For details, see the [Unions feature specification](~/_csharplang/proposals/unions.md). ## Run the sample @@ -139,6 +146,16 @@ Sample: 100% (in range: False) 2.50 units ``` +## Summary + +You built the readings layer of a smart-home telemetry monitor and, in the process, worked through the core union scenarios. You: + +- Declared a `Reading` union and read it with an exhaustive switch, letting the compiler flag any case you don't handle. +- Handled a nullable case, where the compiler requires a `null` arm. +- Replaced a hand-rolled result type with a generic `Result` union that models success or failure without invalid states. +- Reused a generic `Sample` union across several numeric types with a constraint, and added members to the union declaration. +- Built a custom `[Union]` struct that avoids boxing for performance-sensitive code. + ## Related content - [Closed hierarchies tutorial](closed-hierarchies.md) From 731394d704876cd50c8a23b0b09c44dcd68455b0 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 2 Jul 2026 15:42:25 -0400 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/csharp/whats-new/tutorials/unions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/csharp/whats-new/tutorials/unions.md b/docs/csharp/whats-new/tutorials/unions.md index 07869d90b8af9..38fdbf745a58c 100644 --- a/docs/csharp/whats-new/tutorials/unions.md +++ b/docs/csharp/whats-new/tutorials/unions.md @@ -5,7 +5,7 @@ author: billwagner ms.author: wiwagn ms.service: dotnet-csharp ms.topic: tutorial -ms.date: 07/02s/2026 +ms.date: 07/02/2026 ai-usage: ai-assisted #customer intent: As a C# developer, I model a value that can be one of several types so the compiler enforces that I handle every case. --- From aa054211714f3a9ac81d1fd2986e2ac1a266bfb1 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 2 Jul 2026 15:55:06 -0400 Subject: [PATCH 5/7] Update sample and code. --- .../whats-new/tutorials/closed-hierarchies.md | 2 +- .../SmartHome.App/Program.cs | 101 ++++++++++-------- .../SmartHome.Core/Quantity.cs | 4 +- 3 files changed, 59 insertions(+), 48 deletions(-) diff --git a/docs/csharp/whats-new/tutorials/closed-hierarchies.md b/docs/csharp/whats-new/tutorials/closed-hierarchies.md index 0b01bb80ccceb..c39a5562df843 100644 --- a/docs/csharp/whats-new/tutorials/closed-hierarchies.md +++ b/docs/csharp/whats-new/tutorials/closed-hierarchies.md @@ -110,7 +110,7 @@ The switch over `Single` and `Group` is exhaustive because `Report` is 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="ProgramMain"::: +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="ClosedMain"::: Run the app: diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs index 42193d19d958d..2a513f74b11d0 100644 --- a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs @@ -1,52 +1,63 @@ using SmartHome.Core; using SmartHome.Extensions; -// -// Union declarations: a Reading holds a double, bool, or string. -Reading[] readings = [23.5, true, "offline"]; -foreach (Reading reading in readings) +UnionMain(); +ClosedMain(); + +void UnionMain() { - Console.WriteLine($"Reading: {ReadingReporter.Render(reading)}"); + // + // 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 ok = 42.0; + Result bad = new TimeoutException("sensor timed out"); + Console.WriteLine(ResultReporter.Describe(ok)); + Console.WriteLine(ResultReporter.Describe(bad)); + + // Generic union reused across numeric widths. + Sample raw = (byte)200; + Sample 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))); + // } -Console.WriteLine($"Reading: {ReadingReporter.Render(default)}"); - -// Migrate from a conventional result type to a union. -Result ok = 42.0; -Result bad = new TimeoutException("sensor timed out"); -Console.WriteLine(ResultReporter.Describe(ok)); -Console.WriteLine(ResultReporter.Describe(bad)); - -// Generic union reused across numeric widths. -Sample raw = (byte)200; -Sample 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))); - -// Closed hierarchy with an exhaustive switch. -Sensor[] sensors = -[ - new Temperature(21.4), - new Humidity(55), - new Contact(Open: true), -]; -foreach (Sensor sensor in sensors) + +void ClosedMain() { - Console.WriteLine($"Sensor: {SensorReporter.Describe(sensor)}"); + // + // 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)}"); + } + + // + // A DoorContact from another assembly still matches the Contact case. + Sensor frontDoor = new DoorContact(Open: false, Door: "Front"); + Console.WriteLine($"Sensor: {SensorReporter.Describe(frontDoor)}"); + // + + // Generic closed hierarchy. + Report report = new Group( + new Single("Kitchen"), + new Group(new Single("Garage"), new Single("Attic"))); + Console.WriteLine($"Report leaves: {ReportReporter.Count(report)}"); + // } -// - -// -// A DoorContact from another assembly still matches the Contact case. -Sensor frontDoor = new DoorContact(Open: false, Door: "Front"); -Console.WriteLine($"Sensor: {SensorReporter.Describe(frontDoor)}"); -// - -// Generic closed hierarchy. -Report report = new Group( - new Single("Kitchen"), - new Group(new Single("Garage"), new Single("Attic"))); -Console.WriteLine($"Report leaves: {ReportReporter.Count(report)}"); diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs index b04441a8600f1..02fdee4591b67 100644 --- a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Quantity.cs @@ -9,7 +9,7 @@ namespace SmartHome.Core; // behaviors; the non-boxing access members (HasValue and TryGetValue) let the // compiler match each case without boxing. [Union] -public readonly struct Quantity +public readonly struct Quantity : IUnion { private readonly double _value; private readonly bool _isCount; @@ -17,7 +17,7 @@ public readonly struct Quantity public Quantity(int count) => (_value, _isCount) = (count, true); public Quantity(double measure) => (_value, _isCount) = (measure, false); - public object Value => _isCount ? (int)_value : _value; + public object? Value => _isCount ? (int)_value : _value; public bool HasValue => true; public bool TryGetValue(out int value) From e61e5987459c5740f9cdc0e95833f9522f3d8b6d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 2 Jul 2026 16:02:04 -0400 Subject: [PATCH 6/7] Fix preview build errors You might need to add a polyfill --- docs/csharp/whats-new/tutorials/closed-hierarchies.md | 4 ++++ .../telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs diff --git a/docs/csharp/whats-new/tutorials/closed-hierarchies.md b/docs/csharp/whats-new/tutorials/closed-hierarchies.md index c39a5562df843..010e74ecea576 100644 --- a/docs/csharp/whats-new/tutorials/closed-hierarchies.md +++ b/docs/csharp/whats-new/tutorials/closed-hierarchies.md @@ -70,6 +70,10 @@ Start with the sensor model. The monitor supports a fixed set of sensor kinds, s :::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 diff --git a/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs new file mode 100644 index 0000000000000..229927f42009d --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/ClosedPolyfill.cs @@ -0,0 +1,4 @@ +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ClosedAttribute : Attribute { } From 63b5898e13f7cd5c30d7d5ab40f4bbf87ce937e6 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Thu, 2 Jul 2026 16:14:16 -0400 Subject: [PATCH 7/7] Fix snippet name --- docs/csharp/whats-new/tutorials/unions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/csharp/whats-new/tutorials/unions.md b/docs/csharp/whats-new/tutorials/unions.md index 38fdbf745a58c..929ffb56bdece 100644 --- a/docs/csharp/whats-new/tutorials/unions.md +++ b/docs/csharp/whats-new/tutorials/unions.md @@ -123,7 +123,7 @@ The `HasValue` property and the `TryGetValue` overloads let the compiler match e The console app drives each union. Replace the contents of `Program.cs`: -:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="ProgramMain"::: +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="UnionMain"::: Run the app: