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)