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..010e74ecea576 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/closed-hierarchies.md @@ -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 `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 + +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` 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` 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 + +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`, 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) 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..2a513f74b11d0 --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.App/Program.cs @@ -0,0 +1,63 @@ +using SmartHome.Core; +using SmartHome.Extensions; + +UnionMain(); +ClosedMain(); + +void 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 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))); + // +} + +void 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)}"); + } + + // + // 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/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 { } 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..02fdee4591b67 --- /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 : 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; + } +} +// + +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..3ac3e62ae6d2d --- /dev/null +++ b/docs/csharp/whats-new/tutorials/snippets/telemetry-monitor/SmartHome.Core/Readings.cs @@ -0,0 +1,22 @@ +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", + }; + // +} 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/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/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..929ffb56bdece --- /dev/null +++ b/docs/csharp/whats-new/tutorials/unions.md @@ -0,0 +1,163 @@ +--- +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: 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. +--- +# Tutorial: Explore C# union types + +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. + +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 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] +> 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 + +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. + +1. In `SmartHome.Core`, add a file named `Readings.cs` and declare the `Reading` union. A *union declaration* lists the case types in parentheses: + + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Readings.cs" id="ReadingUnion"::: + +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"::: + +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 + +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. + +1. In `SmartHome.Core`, add a file named `Result.cs` with the hand-rolled result class that the migration replaces: + + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultBefore"::: + +1. Replace that class with a generic union. A `Result` holds either a `T` or an `Exception`: + + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Result.cs" id="ResultUnion"::: + +1. Add a method that reads the result by switching over its case types: + + :::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 + +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. + +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: + + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Sample.cs" id="SampleUnion"::: + +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"::: + +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 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"::: + +1. Add a method that consumes the custom union the same way you consume a union declaration: + + :::code language="csharp" source="snippets/telemetry-monitor/SmartHome.Core/Quantity.cs" id="QuantityConsume"::: + +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). + +> [!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 + +The console app drives each union. Replace the contents of `Program.cs`: + +:::code language="csharp" source="snippets/telemetry-monitor/SmartHome.App/Program.cs" id="UnionMain"::: + +Run the app: + +```dotnetcli +dotnet run --project SmartHome.App +``` + +The readings, results, samples, and quantities each print through their exhaustive switches: + +```output +Reading: 23.5 +Reading: on +Reading: offline +Reading: no reading +Success: 42 +Failure: sensor timed out +Sample: 78% (in range: True) +Sample: 100% (in range: False) +3 items +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) +- [Pattern matching overview](../../fundamentals/functional/pattern-matching.md) +- [What's new in C# 15](../csharp-15.md)