From 6f8ca3dd1b7e38f93998b8270c6b5d7b12e3c751 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 20 Mar 2026 11:01:49 -0400 Subject: [PATCH 01/13] Publish unions speclet Publish the unions.md speclet as part of the overall specification. --- docfx.json | 5 ++++- docs/csharp/specification/toc.yml | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docfx.json b/docfx.json index 81e6317284893..1b0777c2a43f6 100644 --- a/docfx.json +++ b/docfx.json @@ -55,7 +55,8 @@ "csharp-12.0/*.md", "csharp-13.0/*.md", "csharp-14.0/*.md", - "collection-expression-arguments.md" + "collection-expression-arguments.md", + "unions.md" ], "src": "_csharplang/proposals", "dest": "csharp/language-reference/proposals", @@ -698,6 +699,7 @@ "_csharplang/proposals/csharp-14.0/extension-operators.md": "Extension operators", "_csharplang/proposals/csharp-14.0/optional-and-named-parameters-in-expression-trees.md": "Optional and named parameters in expression trees", "_csharplang/proposals/collection-expression-arguments.md": "Collection expression arguments", + "_csharplang/proposals/unions.md": "Unions", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 7.md": "C# compiler breaking changes since C# 10", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 8.md": "C# compiler breaking changes since C# 11", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 9.md": "C# compiler breaking changes since C# 12", @@ -826,6 +828,7 @@ "_csharplang/proposals/csharp-14.0/extension-operators.md": "This proposal extends the proposal for extensions to include *extension operators*, where an operator can be an extension member.", "_csharplang/proposals/csharp-14.0/optional-and-named-parameters-in-expression-trees.md": "This proposal allows an expression tree to include named and optional parameters. This enables expression trees to be more flexible in how they are constructed.", "_csharplang/proposals/collection-expression-arguments.md": "This proposal introduces collection expression arguments.", + "_csharplang/proposals/unions.md": "This proposal describes union types and union declarations. Unions allow expressing values from a closed set of types with exhaustive pattern matching.", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 7.md": "Learn about any breaking changes since the initial release of C# 10 and included in C# 11", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 8.md": "Learn about any breaking changes since the initial release of C# 11 and included in C# 12", "_roslyn/docs/compilers/CSharp/Compiler Breaking Changes - DotNet 9.md": "Learn about any breaking changes since the initial release of C# 12 and included in C# 13", diff --git a/docs/csharp/specification/toc.yml b/docs/csharp/specification/toc.yml index 2286c56360af5..14f9da84c8270 100644 --- a/docs/csharp/specification/toc.yml +++ b/docs/csharp/specification/toc.yml @@ -233,6 +233,10 @@ items: href: ../../../_csharplang/proposals/csharp-11.0/low-level-struct-improvements.md - name: Inline arrays href: ../../../_csharplang/proposals/csharp-12.0/inline-arrays.md + - name: Unions + items: + - name: Union types + href: ../../../_csharplang/proposals/unions.md - name: Interfaces items: - name: Variance safety for static interface members From ac5739bf21a748aed7217a634e02aba26fbb94df Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 20 Mar 2026 11:39:27 -0400 Subject: [PATCH 02/13] First draft of unions reference. --- .../snippets/unions/UnionType.cs | 331 ++++++++++++++++++ .../snippets/unions/unions.csproj | 11 + .../language-reference/builtin-types/union.md | 230 ++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/unions.csproj create mode 100644 docs/csharp/language-reference/builtin-types/union.md diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs new file mode 100644 index 0000000000000..514f7d2eaebc3 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs @@ -0,0 +1,331 @@ +// Required until these types are added to the .NET runtime: +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] + public class UnionAttribute : Attribute; + + public interface IUnion + { + object? Value { get; } + } +} + +// +public record class Cat(string Name); +public record class Dog(string Name); +public record class Bird(string Name); +// + +// +public union Pet(Cat, Dog, Bird); +// + +// +public record class None; +public record class Some(T Value); +public union Option(None, Some); +// + +// +public union IntOrString(int, string); +// + +// +public union OneOrMore(T, IEnumerable) +{ + public IEnumerable AsEnumerable() => Value switch + { + T single => [single], + IEnumerable multiple => multiple, + _ => [] + }; +} +// + +// +[System.Runtime.CompilerServices.Union] +public struct Shape : System.Runtime.CompilerServices.IUnion +{ + private readonly object? _value; + + public Shape(Circle value) { _value = value; } + public Shape(Rectangle value) { _value = value; } + + public object? Value => _value; +} + +public record class Circle(double Radius); +public record class Rectangle(double Width, double Height); +// + +// +[System.Runtime.CompilerServices.Union] +public struct IntOrBool : System.Runtime.CompilerServices.IUnion +{ + private readonly int _intValue; + private readonly bool _boolValue; + private readonly byte _tag; // 0 = none, 1 = int, 2 = bool + + public IntOrBool(int? value) + { + if (value.HasValue) + { + _intValue = value.Value; + _tag = 1; + } + } + + public IntOrBool(bool? value) + { + if (value.HasValue) + { + _boolValue = value.Value; + _tag = 2; + } + } + + public object? Value => _tag switch + { + 1 => _intValue, + 2 => _boolValue, + _ => null + }; + + public bool HasValue => _tag != 0; + + public bool TryGetValue(out int value) + { + value = _intValue; + return _tag == 1; + } + + public bool TryGetValue(out bool value) + { + value = _boolValue; + return _tag == 2; + } +} +// + +// +[System.Runtime.CompilerServices.Union] +public class Result : System.Runtime.CompilerServices.IUnion +{ + private readonly object? _value; + + public Result(T? value) { _value = value; } + public Result(Exception? value) { _value = value; } + + public object? Value => _value; +} +// + +// Uncomment when union member providers are available in the compiler: +// +// [System.Runtime.CompilerServices.Union] +// public record class Outcome : Outcome.IUnionMembers +// { +// private readonly object? _value; +// +// private Outcome(object? value) => _value = value; +// +// public interface IUnionMembers +// { +// static Outcome Create(T? value) => new(value); +// static Outcome Create(Exception? value) => new(value); +// object? Value { get; } +// } +// +// object? IUnionMembers.Value => _value; +// } +// + +class Program +{ + static void Main() + { + BasicConversion(); + PatternMatching(); + GenericUnionExample(); + ValueTypeCasesExample(); + BodyMembersExample(); + NullHandling(); + ManualUnionExample(); + NonBoxingExample(); + ClassUnionExample(); + NullableUnionExample(); + } + + // + static void BasicConversion() + { + Pet pet = new Dog("Rex"); + Console.WriteLine(pet.Value); // output: Dog { Name = Rex } + + Pet pet2 = new Cat("Whiskers"); + Console.WriteLine(pet2.Value); // output: Cat { Name = Whiskers } + } + // + + // + static void PatternMatching() + { + Pet pet = new Dog("Rex"); + + var name = pet switch + { + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, + }; + Console.WriteLine(name); // output: Rex + } + // + + // + static void GenericUnionExample() + { + Option some = new Some(42); + Option none = new None(); + + var result = some switch + { + Some s => $"Has value: {s.Value}", + None => "No value", + }; + Console.WriteLine(result); // output: Has value: 42 + + var result2 = none switch + { + Some s => $"Has value: {s.Value}", + None => "No value", + }; + Console.WriteLine(result2); // output: No value + } + // + + // + static void ValueTypeCasesExample() + { + IntOrString val1 = 42; + IntOrString val2 = "hello"; + + Console.WriteLine(Describe(val1)); // output: int: 42 + Console.WriteLine(Describe(val2)); // output: string: hello + + static string Describe(IntOrString value) => value switch + { + int i => $"int: {i}", + string s => $"string: {s}", + null => "null", + }; + } + // + + // + static void BodyMembersExample() + { + OneOrMore single = "hello"; + OneOrMore multiple = new[] { "a", "b", "c" }.AsEnumerable(); + + Console.WriteLine(string.Join(", ", single.AsEnumerable())); // output: hello + Console.WriteLine(string.Join(", ", multiple.AsEnumerable())); // output: a, b, c + } + // + + // + static void NullHandling() + { + Pet pet = default; + Console.WriteLine(pet.Value is null); // output: True + + var description = pet switch + { + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, + null => "no pet", + }; + Console.WriteLine(description); // output: no pet + } + // + + // + static void ManualUnionExample() + { + Shape shape = new Shape(new Circle(5.0)); + + var area = shape switch + { + Circle c => Math.PI * c.Radius * c.Radius, + Rectangle r => r.Width * r.Height, + }; + Console.WriteLine($"{area:F2}"); // output: 78.54 + } + // + + // + static void NonBoxingExample() + { + IntOrBool val = new IntOrBool((int?)42); + + var description = val switch + { + int i => $"int: {i}", + bool b => $"bool: {b}", + }; + Console.WriteLine(description); // output: int: 42 + } + // + + // + static void ClassUnionExample() + { + Result ok = new Result("success"); + Result err = new Result(new InvalidOperationException("failed")); + + Console.WriteLine(Describe(ok)); // output: OK: success + Console.WriteLine(Describe(err)); // output: Error: failed + + static string Describe(Result result) => result switch + { + string s => $"OK: {s}", + Exception e => $"Error: {e.Message}", + null => "null", + }; + } + // + + // + static void NullableUnionExample() + { + Pet? maybePet = new Dog("Buddy"); + Pet? noPet = null; + + Console.WriteLine(Describe(maybePet)); // output: Dog: Buddy + Console.WriteLine(Describe(noPet)); // output: no pet + + static string Describe(Pet? pet) => pet switch + { + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, + null => "no pet", + }; + } + // + + // Uncomment when union member providers are available in the compiler: + // + // static void MemberProviderExample() + // { + // Outcome ok = "success"; + // var msg = ok switch + // { + // string s => $"OK: {s}", + // Exception e => $"Error: {e.Message}", + // }; + // Console.WriteLine(msg); + // } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/unions.csproj b/docs/csharp/language-reference/builtin-types/snippets/unions/unions.csproj new file mode 100644 index 0000000000000..c2cb17a624618 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/unions.csproj @@ -0,0 +1,11 @@ + + + + Exe + net11.0 + preview + enable + enable + + + diff --git a/docs/csharp/language-reference/builtin-types/union.md b/docs/csharp/language-reference/builtin-types/union.md new file mode 100644 index 0000000000000..67149b71d0e06 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/union.md @@ -0,0 +1,230 @@ +--- +title: "Union types" +description: Learn about union types in C#. Unions express values from a closed set of types with exhaustive pattern matching support. +ms.date: 03/20/2026 +f1_keywords: + - "union_CSharpKeyword" +helpviewer_keywords: + - "union keyword [C#]" + - "union type [C#]" + - "case type [C#]" +ai-usage: ai-assisted +--- +# Union types (C# reference) + +A *union type* represents a value that can be one of several *case types*. Unions provide implicit conversions from each case type, exhaustive pattern matching, and enhanced nullability tracking. You declare a union type with the `union` keyword: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="BasicDeclaration"::: + +This declaration creates a `Pet` union with three case types: `Cat`, `Dog`, and `Bird`. You can assign any case type value to a `Pet` variable, and the compiler ensures that `switch` expressions cover all case types. + +[!INCLUDE[csharp-version-note](../includes/initial-version.md)] + +> [!IMPORTANT] +> In .NET 10 Preview 2, the `UnionAttribute` and `IUnion` interface aren't included in the runtime. You must declare them yourself to use union types. See [Union interfaces](#union-interfaces) for the required declarations. + +## Union declarations + +A union declaration specifies a name and a list of case types: + +```csharp +public union Pet(Cat, Dog, Bird); +``` + +The compiler lowers a union declaration to a `struct` with the `[Union]` attribute, a public constructor for each case type, and a `Value` property. For example, the preceding `Pet` declaration becomes equivalent to: + +```csharp +[Union] public struct Pet : IUnion +{ + public Pet(Cat value) => Value = value; + public Pet(Dog value) => Value = value; + public Pet(Bird value) => Value = value; + public object? Value { get; } +} +``` + +You don't write this lowered form directly — the `union` keyword generates it for you. + +### Case types + +Case types can be any type that converts to `object`, including classes, structs, interfaces, type parameters, nullable types, and other unions. The following examples show different case type possibilities: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="CaseTypes"::: +:::code language="csharp" source="snippets/unions/UnionType.cs" id="GenericUnion"::: +:::code language="csharp" source="snippets/unions/UnionType.cs" id="ValueTypeCases"::: + +When a case type is a value type (like `int`), the value is boxed when stored in the union's `Value` property. Unions store their contents as a single `object?` reference. + +### Body members + +A union declaration can include a body with additional members, just like a struct: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="BodyMembers"::: + +Instance fields, auto-properties, and field-like events aren't permitted in union declarations. You also can't declare public constructors with a single parameter, because the compiler generates those as union creation members. + +## Union conversions + +An implicit *union conversion* exists from each case type to the union type. You don't need to call a constructor explicitly: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="BasicConversion"::: + +Union conversions work by calling the corresponding generated constructor. If a user-defined implicit conversion operator exists for the same type, the user-defined operator takes priority over the union conversion. See the [language specification](~/_csharplang/proposals/unions.md) for details on conversion priority. + +A union conversion to a nullable union struct (`T?`) also works when `T` is a union type: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="NullableUnionExample"::: + +## Union matching + +When you pattern match on a union type, patterns apply to the union's `Value` property — not the union value itself. This "unwrapping" behavior means the union is transparent to pattern matching: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="PatternMatching"::: + +Two patterns are exceptions to this rule: the `var` pattern and the discard `_` pattern apply to the union value itself, not its `Value` property. Use `var` to capture the union value: + +```csharp +if (GetPet() is var pet) { /* pet is the Pet union value */ } +``` + +In logical patterns, each branch follows the unwrapping rule individually: + +```csharp +GetPet() switch +{ + var pet and not null => ..., // 'var pet' captures the Pet; 'not null' checks Value +} +``` + +> [!NOTE] +> Because patterns apply to `Value`, a pattern like `pet is Pet` typically doesn't match, since `Pet` is tested against the *contents* of the union, not the union itself. + +### Null matching + +For struct unions, the `null` pattern checks whether `Value` is null: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="NullHandling"::: + +For class-based unions, `null` succeeds when either the union reference itself is null or its `Value` property is null: + +```csharp +Result? result = null; +if (result is null) { /* true — the reference is null */ } + +Result empty = new Result((string?)null); +if (empty is null) { /* true — Value is null */ } +``` + +For nullable union struct types (`Pet?`), `null` succeeds when the nullable wrapper has no value or when the underlying union's `Value` is null. + +## Union exhaustiveness + +A `switch` expression is exhaustive when it handles all case types of a union. The compiler reports no warning about missing cases: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="PatternMatching"::: + +If the null state of the union's `Value` property is "maybe null," you must also handle `null` to avoid a warning: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="NullHandling"::: + +## Nullability + +The null state of a union's `Value` property is tracked with these rules: + +- When you create a union value from a case type (through a constructor or union conversion), `Value` gets the null state of the incoming value. +- When the non-boxing access pattern's `HasValue` or `TryGetValue(...)` members query the union's contents, the null state of `Value` becomes "not null" on the `true` branch. + +## Union types (advanced) + +Union declarations provide an opinionated, concise syntax. For scenarios where you need different storage strategies, interop support, or want to adapt existing types, you can create union types manually. + +Any class or struct with a `[Union]` attribute is a union type. Manual union types must follow the *basic union pattern*: one or more public constructors with a single parameter (the case types), and a `Value` property of type `object?`. + +### Basic union pattern + +The following example shows a manually declared union type: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="ManualBasicPattern"::: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="ManualUnionExample"::: + +### Non-boxing access pattern + +A manual union type can optionally implement the *non-boxing access pattern* to enable strongly typed access to value-type cases without boxing during pattern matching. This pattern requires: + +- A `HasValue` property of type `bool` that returns `true` when `Value` isn't null. +- A `TryGetValue` method for each case type that returns `bool` and delivers the value through an `out` parameter. + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="NonBoxingAccessPattern"::: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="NonBoxingExample"::: + +The compiler prefers `TryGetValue` over the `Value` property when implementing pattern matching, which avoids boxing value types. + + + + +### Class-based union types + +A class can also be a union type. This is useful when you need reference semantics or inheritance: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="ClassUnion"::: + +:::code language="csharp" source="snippets/unions/UnionType.cs" id="ClassUnionExample"::: + +For class-based unions, the `null` pattern matches both a null reference and a null `Value`. + +### Well-formedness + +The compiler assumes that manual union types satisfy these behavioral rules: + +- **Soundness**: `Value` always evaluates to null or a value of one of the case types, including for the default value. +- **Stability**: If a union value is created from a case type, `Value` matches that case type (or is null if the input was null). +- **Creation equivalence**: If a value is implicitly convertible to two different case types, both creation members produce the same observable behavior. +- **Access pattern consistency**: The `HasValue` and `TryGetValue` members, if present, behave equivalently to checking `Value` directly. + +## Union interfaces + +The following interfaces support union types at compile time and runtime: + +```csharp +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] + public class UnionAttribute : Attribute; + + public interface IUnion + { + object? Value { get; } + } +} +``` + +Union declarations generated by the compiler implement `IUnion` automatically. You can check for any union value at runtime using `IUnion`: + +```csharp +if (value is IUnion { Value: null }) { /* the union's value is null */ } +``` + +> [!IMPORTANT] +> In .NET 10 Preview 2, these types aren't included in the runtime. You must declare them in your project to use union types. They'll be included in a future .NET preview. + +## C# language specification + +For more information, see the [Unions](~/_csharplang/proposals/unions.md) feature specification. + +## See also + +- [The C# type system](../../fundamentals/types/index.md) +- [Pattern matching](../operators/patterns.md) +- [Switch expression](../operators/switch-expression.md) +- [Value types](value-types.md) +- [Records](record.md) From 640e3f683f11f51bb80789c94022f3794ecf3086 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 20 Mar 2026 11:52:36 -0400 Subject: [PATCH 03/13] Refactor sample Refactor the sample into a better organization. --- .../snippets/unions/BasicUnion.cs | 44 +++ .../snippets/unions/BodyMembers.cs | 30 ++ .../snippets/unions/ClassUnion.cs | 38 ++ .../snippets/unions/GenericUnion.cs | 35 ++ .../snippets/unions/ManualUnion.cs | 37 ++ .../snippets/unions/MemberProvider.cs | 36 ++ .../snippets/unions/NonBoxingAccess.cs | 70 ++++ .../snippets/unions/NullHandling.cs | 44 +++ .../builtin-types/snippets/unions/Program.cs | 10 + .../snippets/unions/RuntimePolyfill.cs | 11 + .../snippets/unions/UnionType.cs | 331 ------------------ .../snippets/unions/ValueTypeCases.cs | 29 ++ .../language-reference/builtin-types/union.md | 36 +- 13 files changed, 402 insertions(+), 349 deletions(-) create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/BasicUnion.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/BodyMembers.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/ClassUnion.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/GenericUnion.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/ManualUnion.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/MemberProvider.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/NonBoxingAccess.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/NullHandling.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/Program.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/RuntimePolyfill.cs delete mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs create mode 100644 docs/csharp/language-reference/builtin-types/snippets/unions/ValueTypeCases.cs diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/BasicUnion.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/BasicUnion.cs new file mode 100644 index 0000000000000..8ed54f5ab38c7 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/BasicUnion.cs @@ -0,0 +1,44 @@ +// +public record class Cat(string Name); +public record class Dog(string Name); +public record class Bird(string Name); +// + +// +public union Pet(Cat, Dog, Bird); +// + +public static class BasicUnionScenario +{ + public static void Run() + { + BasicConversion(); + PatternMatching(); + } + + // + static void BasicConversion() + { + Pet pet = new Dog("Rex"); + Console.WriteLine(pet.Value); // output: Dog { Name = Rex } + + Pet pet2 = new Cat("Whiskers"); + Console.WriteLine(pet2.Value); // output: Cat { Name = Whiskers } + } + // + + // + static void PatternMatching() + { + Pet pet = new Dog("Rex"); + + var name = pet switch + { + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, + }; + Console.WriteLine(name); // output: Rex + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/BodyMembers.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/BodyMembers.cs new file mode 100644 index 0000000000000..8ce8467dec5e0 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/BodyMembers.cs @@ -0,0 +1,30 @@ +// +public union OneOrMore(T, IEnumerable) +{ + public IEnumerable AsEnumerable() => Value switch + { + T single => [single], + IEnumerable multiple => multiple, + _ => [] + }; +} +// + +public static class BodyMembersScenario +{ + public static void Run() + { + BodyMembersExample(); + } + + // + static void BodyMembersExample() + { + OneOrMore single = "hello"; + OneOrMore multiple = new[] { "a", "b", "c" }.AsEnumerable(); + + Console.WriteLine(string.Join(", ", single.AsEnumerable())); // output: hello + Console.WriteLine(string.Join(", ", multiple.AsEnumerable())); // output: a, b, c + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/ClassUnion.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/ClassUnion.cs new file mode 100644 index 0000000000000..5ae6b433eeaf3 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/ClassUnion.cs @@ -0,0 +1,38 @@ +// +[System.Runtime.CompilerServices.Union] +public class Result : System.Runtime.CompilerServices.IUnion +{ + private readonly object? _value; + + public Result(T? value) { _value = value; } + public Result(Exception? value) { _value = value; } + + public object? Value => _value; +} +// + +public static class ClassUnionScenario +{ + public static void Run() + { + ClassUnionExample(); + } + + // + static void ClassUnionExample() + { + Result ok = new Result("success"); + Result err = new Result(new InvalidOperationException("failed")); + + Console.WriteLine(Describe(ok)); // output: OK: success + Console.WriteLine(Describe(err)); // output: Error: failed + + static string Describe(Result result) => result switch + { + string s => $"OK: {s}", + Exception e => $"Error: {e.Message}", + null => "null", + }; + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/GenericUnion.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/GenericUnion.cs new file mode 100644 index 0000000000000..9593fd399e6c1 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/GenericUnion.cs @@ -0,0 +1,35 @@ +// +public record class None; +public record class Some(T Value); +public union Option(None, Some); +// + +public static class GenericUnionScenario +{ + public static void Run() + { + GenericUnionExample(); + } + + // + static void GenericUnionExample() + { + Option some = new Some(42); + Option none = new None(); + + var result = some switch + { + Some s => $"Has value: {s.Value}", + None => "No value", + }; + Console.WriteLine(result); // output: Has value: 42 + + var result2 = none switch + { + Some s => $"Has value: {s.Value}", + None => "No value", + }; + Console.WriteLine(result2); // output: No value + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/ManualUnion.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/ManualUnion.cs new file mode 100644 index 0000000000000..b4055e06cf222 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/ManualUnion.cs @@ -0,0 +1,37 @@ +// +[System.Runtime.CompilerServices.Union] +public struct Shape : System.Runtime.CompilerServices.IUnion +{ + private readonly object? _value; + + public Shape(Circle value) { _value = value; } + public Shape(Rectangle value) { _value = value; } + + public object? Value => _value; +} + +public record class Circle(double Radius); +public record class Rectangle(double Width, double Height); +// + +public static class ManualUnionScenario +{ + public static void Run() + { + ManualUnionExample(); + } + + // + static void ManualUnionExample() + { + Shape shape = new Shape(new Circle(5.0)); + + var area = shape switch + { + Circle c => Math.PI * c.Radius * c.Radius, + Rectangle r => r.Width * r.Height, + }; + Console.WriteLine($"{area:F2}"); // output: 78.54 + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/MemberProvider.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/MemberProvider.cs new file mode 100644 index 0000000000000..5505875ebccaa --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/MemberProvider.cs @@ -0,0 +1,36 @@ +// Uncomment when union member providers are available in the compiler: + +// +// [System.Runtime.CompilerServices.Union] +// public record class Outcome : Outcome.IUnionMembers +// { +// private readonly object? _value; +// +// private Outcome(object? value) => _value = value; +// +// public interface IUnionMembers +// { +// static Outcome Create(T? value) => new(value); +// static Outcome Create(Exception? value) => new(value); +// object? Value { get; } +// } +// +// object? IUnionMembers.Value => _value; +// } +// + +// +// public static class MemberProviderScenario +// { +// public static void Run() +// { +// Outcome ok = "success"; +// var msg = ok switch +// { +// string s => $"OK: {s}", +// Exception e => $"Error: {e.Message}", +// }; +// Console.WriteLine(msg); +// } +// } +// diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/NonBoxingAccess.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/NonBoxingAccess.cs new file mode 100644 index 0000000000000..a0b3d60663a99 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/NonBoxingAccess.cs @@ -0,0 +1,70 @@ +// +[System.Runtime.CompilerServices.Union] +public struct IntOrBool : System.Runtime.CompilerServices.IUnion +{ + private readonly int _intValue; + private readonly bool _boolValue; + private readonly byte _tag; // 0 = none, 1 = int, 2 = bool + + public IntOrBool(int? value) + { + if (value.HasValue) + { + _intValue = value.Value; + _tag = 1; + } + } + + public IntOrBool(bool? value) + { + if (value.HasValue) + { + _boolValue = value.Value; + _tag = 2; + } + } + + public object? Value => _tag switch + { + 1 => _intValue, + 2 => _boolValue, + _ => null + }; + + public bool HasValue => _tag != 0; + + public bool TryGetValue(out int value) + { + value = _intValue; + return _tag == 1; + } + + public bool TryGetValue(out bool value) + { + value = _boolValue; + return _tag == 2; + } +} +// + +public static class NonBoxingAccessScenario +{ + public static void Run() + { + NonBoxingExample(); + } + + // + static void NonBoxingExample() + { + IntOrBool val = new IntOrBool((int?)42); + + var description = val switch + { + int i => $"int: {i}", + bool b => $"bool: {b}", + }; + Console.WriteLine(description); // output: int: 42 + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/NullHandling.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/NullHandling.cs new file mode 100644 index 0000000000000..67e1fb8950359 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/NullHandling.cs @@ -0,0 +1,44 @@ +public static class NullHandlingScenario +{ + public static void Run() + { + NullHandling(); + NullableUnionExample(); + } + + // + static void NullHandling() + { + Pet pet = default; + Console.WriteLine(pet.Value is null); // output: True + + var description = pet switch + { + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, + null => "no pet", + }; + Console.WriteLine(description); // output: no pet + } + // + + // + static void NullableUnionExample() + { + Pet? maybePet = new Dog("Buddy"); + Pet? noPet = null; + + Console.WriteLine(Describe(maybePet)); // output: Dog: Buddy + Console.WriteLine(Describe(noPet)); // output: no pet + + static string Describe(Pet? pet) => pet switch + { + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, + null => "no pet", + }; + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/Program.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/Program.cs new file mode 100644 index 0000000000000..eca889a8175e5 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/Program.cs @@ -0,0 +1,10 @@ +BasicUnionScenario.Run(); +GenericUnionScenario.Run(); +ValueTypeCasesScenario.Run(); +BodyMembersScenario.Run(); +NullHandlingScenario.Run(); +ManualUnionScenario.Run(); +NonBoxingAccessScenario.Run(); +ClassUnionScenario.Run(); +// Uncomment when union member providers are available in the compiler: +// MemberProviderScenario.Run(); diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/RuntimePolyfill.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/RuntimePolyfill.cs new file mode 100644 index 0000000000000..02cb91197c2a1 --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/RuntimePolyfill.cs @@ -0,0 +1,11 @@ +// Remove this file when UnionAttribute and IUnion are included in the .NET runtime. +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] + public class UnionAttribute : Attribute; + + public interface IUnion + { + object? Value { get; } + } +} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs deleted file mode 100644 index 514f7d2eaebc3..0000000000000 --- a/docs/csharp/language-reference/builtin-types/snippets/unions/UnionType.cs +++ /dev/null @@ -1,331 +0,0 @@ -// Required until these types are added to the .NET runtime: -namespace System.Runtime.CompilerServices -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)] - public class UnionAttribute : Attribute; - - public interface IUnion - { - object? Value { get; } - } -} - -// -public record class Cat(string Name); -public record class Dog(string Name); -public record class Bird(string Name); -// - -// -public union Pet(Cat, Dog, Bird); -// - -// -public record class None; -public record class Some(T Value); -public union Option(None, Some); -// - -// -public union IntOrString(int, string); -// - -// -public union OneOrMore(T, IEnumerable) -{ - public IEnumerable AsEnumerable() => Value switch - { - T single => [single], - IEnumerable multiple => multiple, - _ => [] - }; -} -// - -// -[System.Runtime.CompilerServices.Union] -public struct Shape : System.Runtime.CompilerServices.IUnion -{ - private readonly object? _value; - - public Shape(Circle value) { _value = value; } - public Shape(Rectangle value) { _value = value; } - - public object? Value => _value; -} - -public record class Circle(double Radius); -public record class Rectangle(double Width, double Height); -// - -// -[System.Runtime.CompilerServices.Union] -public struct IntOrBool : System.Runtime.CompilerServices.IUnion -{ - private readonly int _intValue; - private readonly bool _boolValue; - private readonly byte _tag; // 0 = none, 1 = int, 2 = bool - - public IntOrBool(int? value) - { - if (value.HasValue) - { - _intValue = value.Value; - _tag = 1; - } - } - - public IntOrBool(bool? value) - { - if (value.HasValue) - { - _boolValue = value.Value; - _tag = 2; - } - } - - public object? Value => _tag switch - { - 1 => _intValue, - 2 => _boolValue, - _ => null - }; - - public bool HasValue => _tag != 0; - - public bool TryGetValue(out int value) - { - value = _intValue; - return _tag == 1; - } - - public bool TryGetValue(out bool value) - { - value = _boolValue; - return _tag == 2; - } -} -// - -// -[System.Runtime.CompilerServices.Union] -public class Result : System.Runtime.CompilerServices.IUnion -{ - private readonly object? _value; - - public Result(T? value) { _value = value; } - public Result(Exception? value) { _value = value; } - - public object? Value => _value; -} -// - -// Uncomment when union member providers are available in the compiler: -// -// [System.Runtime.CompilerServices.Union] -// public record class Outcome : Outcome.IUnionMembers -// { -// private readonly object? _value; -// -// private Outcome(object? value) => _value = value; -// -// public interface IUnionMembers -// { -// static Outcome Create(T? value) => new(value); -// static Outcome Create(Exception? value) => new(value); -// object? Value { get; } -// } -// -// object? IUnionMembers.Value => _value; -// } -// - -class Program -{ - static void Main() - { - BasicConversion(); - PatternMatching(); - GenericUnionExample(); - ValueTypeCasesExample(); - BodyMembersExample(); - NullHandling(); - ManualUnionExample(); - NonBoxingExample(); - ClassUnionExample(); - NullableUnionExample(); - } - - // - static void BasicConversion() - { - Pet pet = new Dog("Rex"); - Console.WriteLine(pet.Value); // output: Dog { Name = Rex } - - Pet pet2 = new Cat("Whiskers"); - Console.WriteLine(pet2.Value); // output: Cat { Name = Whiskers } - } - // - - // - static void PatternMatching() - { - Pet pet = new Dog("Rex"); - - var name = pet switch - { - Dog d => d.Name, - Cat c => c.Name, - Bird b => b.Name, - }; - Console.WriteLine(name); // output: Rex - } - // - - // - static void GenericUnionExample() - { - Option some = new Some(42); - Option none = new None(); - - var result = some switch - { - Some s => $"Has value: {s.Value}", - None => "No value", - }; - Console.WriteLine(result); // output: Has value: 42 - - var result2 = none switch - { - Some s => $"Has value: {s.Value}", - None => "No value", - }; - Console.WriteLine(result2); // output: No value - } - // - - // - static void ValueTypeCasesExample() - { - IntOrString val1 = 42; - IntOrString val2 = "hello"; - - Console.WriteLine(Describe(val1)); // output: int: 42 - Console.WriteLine(Describe(val2)); // output: string: hello - - static string Describe(IntOrString value) => value switch - { - int i => $"int: {i}", - string s => $"string: {s}", - null => "null", - }; - } - // - - // - static void BodyMembersExample() - { - OneOrMore single = "hello"; - OneOrMore multiple = new[] { "a", "b", "c" }.AsEnumerable(); - - Console.WriteLine(string.Join(", ", single.AsEnumerable())); // output: hello - Console.WriteLine(string.Join(", ", multiple.AsEnumerable())); // output: a, b, c - } - // - - // - static void NullHandling() - { - Pet pet = default; - Console.WriteLine(pet.Value is null); // output: True - - var description = pet switch - { - Dog d => d.Name, - Cat c => c.Name, - Bird b => b.Name, - null => "no pet", - }; - Console.WriteLine(description); // output: no pet - } - // - - // - static void ManualUnionExample() - { - Shape shape = new Shape(new Circle(5.0)); - - var area = shape switch - { - Circle c => Math.PI * c.Radius * c.Radius, - Rectangle r => r.Width * r.Height, - }; - Console.WriteLine($"{area:F2}"); // output: 78.54 - } - // - - // - static void NonBoxingExample() - { - IntOrBool val = new IntOrBool((int?)42); - - var description = val switch - { - int i => $"int: {i}", - bool b => $"bool: {b}", - }; - Console.WriteLine(description); // output: int: 42 - } - // - - // - static void ClassUnionExample() - { - Result ok = new Result("success"); - Result err = new Result(new InvalidOperationException("failed")); - - Console.WriteLine(Describe(ok)); // output: OK: success - Console.WriteLine(Describe(err)); // output: Error: failed - - static string Describe(Result result) => result switch - { - string s => $"OK: {s}", - Exception e => $"Error: {e.Message}", - null => "null", - }; - } - // - - // - static void NullableUnionExample() - { - Pet? maybePet = new Dog("Buddy"); - Pet? noPet = null; - - Console.WriteLine(Describe(maybePet)); // output: Dog: Buddy - Console.WriteLine(Describe(noPet)); // output: no pet - - static string Describe(Pet? pet) => pet switch - { - Dog d => d.Name, - Cat c => c.Name, - Bird b => b.Name, - null => "no pet", - }; - } - // - - // Uncomment when union member providers are available in the compiler: - // - // static void MemberProviderExample() - // { - // Outcome ok = "success"; - // var msg = ok switch - // { - // string s => $"OK: {s}", - // Exception e => $"Error: {e.Message}", - // }; - // Console.WriteLine(msg); - // } - // -} diff --git a/docs/csharp/language-reference/builtin-types/snippets/unions/ValueTypeCases.cs b/docs/csharp/language-reference/builtin-types/snippets/unions/ValueTypeCases.cs new file mode 100644 index 0000000000000..5c9349e1e608d --- /dev/null +++ b/docs/csharp/language-reference/builtin-types/snippets/unions/ValueTypeCases.cs @@ -0,0 +1,29 @@ +// +public union IntOrString(int, string); +// + +public static class ValueTypeCasesScenario +{ + public static void Run() + { + ValueTypeCasesExample(); + } + + // + static void ValueTypeCasesExample() + { + IntOrString val1 = 42; + IntOrString val2 = "hello"; + + Console.WriteLine(Describe(val1)); // output: int: 42 + Console.WriteLine(Describe(val2)); // output: string: hello + + static string Describe(IntOrString value) => value switch + { + int i => $"int: {i}", + string s => $"string: {s}", + null => "null", + }; + } + // +} diff --git a/docs/csharp/language-reference/builtin-types/union.md b/docs/csharp/language-reference/builtin-types/union.md index 67149b71d0e06..8d2f90e28daac 100644 --- a/docs/csharp/language-reference/builtin-types/union.md +++ b/docs/csharp/language-reference/builtin-types/union.md @@ -14,7 +14,7 @@ ai-usage: ai-assisted A *union type* represents a value that can be one of several *case types*. Unions provide implicit conversions from each case type, exhaustive pattern matching, and enhanced nullability tracking. You declare a union type with the `union` keyword: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="BasicDeclaration"::: +:::code language="csharp" source="snippets/unions/BasicUnion.cs" id="BasicDeclaration"::: This declaration creates a `Pet` union with three case types: `Cat`, `Dog`, and `Bird`. You can assign any case type value to a `Pet` variable, and the compiler ensures that `switch` expressions cover all case types. @@ -49,9 +49,9 @@ You don't write this lowered form directly — the `union` keyword generates it Case types can be any type that converts to `object`, including classes, structs, interfaces, type parameters, nullable types, and other unions. The following examples show different case type possibilities: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="CaseTypes"::: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="GenericUnion"::: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="ValueTypeCases"::: +:::code language="csharp" source="snippets/unions/BasicUnion.cs" id="CaseTypes"::: +:::code language="csharp" source="snippets/unions/GenericUnion.cs" id="GenericUnion"::: +:::code language="csharp" source="snippets/unions/ValueTypeCases.cs" id="ValueTypeCases"::: When a case type is a value type (like `int`), the value is boxed when stored in the union's `Value` property. Unions store their contents as a single `object?` reference. @@ -59,7 +59,7 @@ When a case type is a value type (like `int`), the value is boxed when stored in A union declaration can include a body with additional members, just like a struct: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="BodyMembers"::: +:::code language="csharp" source="snippets/unions/BodyMembers.cs" id="BodyMembers"::: Instance fields, auto-properties, and field-like events aren't permitted in union declarations. You also can't declare public constructors with a single parameter, because the compiler generates those as union creation members. @@ -67,19 +67,19 @@ Instance fields, auto-properties, and field-like events aren't permitted in unio An implicit *union conversion* exists from each case type to the union type. You don't need to call a constructor explicitly: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="BasicConversion"::: +:::code language="csharp" source="snippets/unions/BasicUnion.cs" id="BasicConversion"::: Union conversions work by calling the corresponding generated constructor. If a user-defined implicit conversion operator exists for the same type, the user-defined operator takes priority over the union conversion. See the [language specification](~/_csharplang/proposals/unions.md) for details on conversion priority. A union conversion to a nullable union struct (`T?`) also works when `T` is a union type: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="NullableUnionExample"::: +:::code language="csharp" source="snippets/unions/NullHandling.cs" id="NullableUnionExample"::: ## Union matching When you pattern match on a union type, patterns apply to the union's `Value` property — not the union value itself. This "unwrapping" behavior means the union is transparent to pattern matching: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="PatternMatching"::: +:::code language="csharp" source="snippets/unions/BasicUnion.cs" id="PatternMatching"::: Two patterns are exceptions to this rule: the `var` pattern and the discard `_` pattern apply to the union value itself, not its `Value` property. Use `var` to capture the union value: @@ -103,7 +103,7 @@ GetPet() switch For struct unions, the `null` pattern checks whether `Value` is null: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="NullHandling"::: +:::code language="csharp" source="snippets/unions/NullHandling.cs" id="NullHandling"::: For class-based unions, `null` succeeds when either the union reference itself is null or its `Value` property is null: @@ -121,11 +121,11 @@ For nullable union struct types (`Pet?`), `null` succeeds when the nullable wrap A `switch` expression is exhaustive when it handles all case types of a union. The compiler reports no warning about missing cases: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="PatternMatching"::: +:::code language="csharp" source="snippets/unions/BasicUnion.cs" id="PatternMatching"::: If the null state of the union's `Value` property is "maybe null," you must also handle `null` to avoid a warning: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="NullHandling"::: +:::code language="csharp" source="snippets/unions/NullHandling.cs" id="NullHandling"::: ## Nullability @@ -144,9 +144,9 @@ Any class or struct with a `[Union]` attribute is a union type. Manual union typ The following example shows a manually declared union type: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="ManualBasicPattern"::: +:::code language="csharp" source="snippets/unions/ManualUnion.cs" id="ManualBasicPattern"::: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="ManualUnionExample"::: +:::code language="csharp" source="snippets/unions/ManualUnion.cs" id="ManualUnionExample"::: ### Non-boxing access pattern @@ -155,9 +155,9 @@ A manual union type can optionally implement the *non-boxing access pattern* to - A `HasValue` property of type `bool` that returns `true` when `Value` isn't null. - A `TryGetValue` method for each case type that returns `bool` and delivers the value through an `out` parameter. -:::code language="csharp" source="snippets/unions/UnionType.cs" id="NonBoxingAccessPattern"::: +:::code language="csharp" source="snippets/unions/NonBoxingAccess.cs" id="NonBoxingAccessPattern"::: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="NonBoxingExample"::: +:::code language="csharp" source="snippets/unions/NonBoxingAccess.cs" id="NonBoxingExample"::: The compiler prefers `TryGetValue` over the `Value` property when implementing pattern matching, which avoids boxing value types. @@ -167,7 +167,7 @@ The compiler prefers `TryGetValue` over the `Value` property when implementing p A union type can delegate its union members to a nested `IUnionMembers` interface. When this interface is present, the compiler looks for `Create` factory methods instead of constructors: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="MemberProvider"::: +:::code language="csharp" source="snippets/unions/MemberProvider.cs" id="MemberProvider"::: Union member providers are useful when the union type needs a private constructor or when the creation logic requires a factory pattern, such as with `record class` union types. --> @@ -176,9 +176,9 @@ Union member providers are useful when the union type needs a private constructo A class can also be a union type. This is useful when you need reference semantics or inheritance: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="ClassUnion"::: +:::code language="csharp" source="snippets/unions/ClassUnion.cs" id="ClassUnion"::: -:::code language="csharp" source="snippets/unions/UnionType.cs" id="ClassUnionExample"::: +:::code language="csharp" source="snippets/unions/ClassUnion.cs" id="ClassUnionExample"::: For class-based unions, the `null` pattern matches both a null reference and a null `Value`. From 72d7215365356d28eb0cae24a3ac6ee0e200fd9d Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 20 Mar 2026 13:18:30 -0400 Subject: [PATCH 04/13] Content edit on main article. --- .../language-reference/builtin-types/union.md | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/csharp/language-reference/builtin-types/union.md b/docs/csharp/language-reference/builtin-types/union.md index 8d2f90e28daac..352aa1470bb5f 100644 --- a/docs/csharp/language-reference/builtin-types/union.md +++ b/docs/csharp/language-reference/builtin-types/union.md @@ -21,7 +21,7 @@ This declaration creates a `Pet` union with three case types: `Cat`, `Dog`, and [!INCLUDE[csharp-version-note](../includes/initial-version.md)] > [!IMPORTANT] -> In .NET 10 Preview 2, the `UnionAttribute` and `IUnion` interface aren't included in the runtime. You must declare them yourself to use union types. See [Union interfaces](#union-interfaces) for the required declarations. +> In .NET 10 Preview 2, the `UnionAttribute` and `IUnion` interface aren't included in the runtime. You must declare them yourself to use union types. See [Union attribute, interface, and lowering](#union-attribute-interface-and-lowering) for the required declarations. ## Union declarations @@ -31,19 +31,7 @@ A union declaration specifies a name and a list of case types: public union Pet(Cat, Dog, Bird); ``` -The compiler lowers a union declaration to a `struct` with the `[Union]` attribute, a public constructor for each case type, and a `Value` property. For example, the preceding `Pet` declaration becomes equivalent to: - -```csharp -[Union] public struct Pet : IUnion -{ - public Pet(Cat value) => Value = value; - public Pet(Dog value) => Value = value; - public Pet(Bird value) => Value = value; - public object? Value { get; } -} -``` - -You don't write this lowered form directly — the `union` keyword generates it for you. +The compiler lowers a union declaration to a `struct` with the `[Union]` attribute, a public constructor for each case type, and a `Value` property. You don't write this lowered form directly—the `union` keyword generates it for you. See [Union attribute, interface, and lowering](#union-attribute-interface-and-lowering) for the generated code. ### Case types @@ -81,18 +69,18 @@ When you pattern match on a union type, patterns apply to the union's `Value` pr :::code language="csharp" source="snippets/unions/BasicUnion.cs" id="PatternMatching"::: -Two patterns are exceptions to this rule: the `var` pattern and the discard `_` pattern apply to the union value itself, not its `Value` property. Use `var` to capture the union value: +Two patterns are exceptions to this rule: the `var` pattern and the discard `_` pattern apply to the union value itself, not its `Value` property. Use `var` to capture the union value when `GetPet()` returns a `Pet?` (`Nullable`): ```csharp -if (GetPet() is var pet) { /* pet is the Pet union value */ } +if (GetPet() is var pet) { /* pet is the Pet? value returned from GetPet */ } ``` -In logical patterns, each branch follows the unwrapping rule individually: +In logical patterns, each branch follows the unwrapping rule individually. The following pattern tests that the `Pet?` isn't null *and* its `Value` isn't null: ```csharp GetPet() switch { - var pet and not null => ..., // 'var pet' captures the Pet; 'not null' checks Value + var pet and not null => ..., // 'var pet' captures the Pet?; 'not null' checks Value } ``` @@ -119,7 +107,7 @@ For nullable union struct types (`Pet?`), `null` succeeds when the nullable wrap ## Union exhaustiveness -A `switch` expression is exhaustive when it handles all case types of a union. The compiler reports no warning about missing cases: +A `switch` expression is exhaustive when it handles all case types of a union. The compiler warns only if a case type isn't handled. A catch-all arm for any type isn't needed: :::code language="csharp" source="snippets/unions/BasicUnion.cs" id="PatternMatching"::: @@ -134,11 +122,17 @@ The null state of a union's `Value` property is tracked with these rules: - When you create a union value from a case type (through a constructor or union conversion), `Value` gets the null state of the incoming value. - When the non-boxing access pattern's `HasValue` or `TryGetValue(...)` members query the union's contents, the null state of `Value` becomes "not null" on the `true` branch. -## Union types (advanced) +## Custom union types Union declarations provide an opinionated, concise syntax. For scenarios where you need different storage strategies, interop support, or want to adapt existing types, you can create union types manually. -Any class or struct with a `[Union]` attribute is a union type. Manual union types must follow the *basic union pattern*: one or more public constructors with a single parameter (the case types), and a `Value` property of type `object?`. +Any class or struct with a `[Union]` attribute is a *union type* if it follows the *basic union pattern*. The basic union pattern requires: + +- A `[System.Runtime.CompilerServices.Union]` attribute on the type. +- One or more public constructors, each with a single by-value or `in` parameter. The parameter type of each constructor defines a *case type*. +- A public `Value` property of type `object?` (or `object`) with a `get` accessor. + +All union members must be public. The compiler uses these members to implement union conversions, pattern matching, and exhaustiveness checks. ### Basic union pattern @@ -186,14 +180,14 @@ For class-based unions, the `null` pattern matches both a null reference and a n The compiler assumes that manual union types satisfy these behavioral rules: -- **Soundness**: `Value` always evaluates to null or a value of one of the case types, including for the default value. +- **Soundness**: `Value` always returns null or a value of one of the case types—never a value of a different type. For struct unions, `default` produces a `Value` of `null`. - **Stability**: If a union value is created from a case type, `Value` matches that case type (or is null if the input was null). - **Creation equivalence**: If a value is implicitly convertible to two different case types, both creation members produce the same observable behavior. - **Access pattern consistency**: The `HasValue` and `TryGetValue` members, if present, behave equivalently to checking `Value` directly. -## Union interfaces +## Union attribute, interface, and lowering -The following interfaces support union types at compile time and runtime: +The following attribute and interface support union types at compile time and runtime: ```csharp namespace System.Runtime.CompilerServices @@ -214,6 +208,24 @@ Union declarations generated by the compiler implement `IUnion` automatically. Y if (value is IUnion { Value: null }) { /* the union's value is null */ } ``` +The compiler lowers a `union` declaration to a struct that implements `IUnion`. For example, the `Pet` declaration: + +```csharp +public union Pet(Cat, Dog, Bird); +``` + +becomes equivalent to: + +```csharp +[Union] public struct Pet : IUnion +{ + public Pet(Cat value) => Value = value; + public Pet(Dog value) => Value = value; + public Pet(Bird value) => Value = value; + public object? Value { get; } +} +``` + > [!IMPORTANT] > In .NET 10 Preview 2, these types aren't included in the runtime. You must declare them in your project to use union types. They'll be included in a future .NET preview. From 7db55b4e5cd0f588d67fa90ad6a70e6c0728ac29 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 20 Mar 2026 13:30:20 -0400 Subject: [PATCH 05/13] Update existing docs A few other language reference articles should reference union types. --- .../builtin-types/value-types.md | 1 + .../language-reference/operators/patterns.md | 24 +++++++++++++++ .../operators/switch-expression.md | 2 ++ docs/csharp/whats-new/csharp-15.md | 30 +++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/docs/csharp/language-reference/builtin-types/value-types.md b/docs/csharp/language-reference/builtin-types/value-types.md index e6d03c3df25ab..27bb3a7a6e738 100644 --- a/docs/csharp/language-reference/builtin-types/value-types.md +++ b/docs/csharp/language-reference/builtin-types/value-types.md @@ -30,6 +30,7 @@ A value type can be one of the two following kinds: - a [structure type](struct.md), which encapsulates data and related functionality - an [enumeration type](enum.md), which is defined by a set of named constants and represents a choice or a combination of choices +- a [union type](union.md), which defines a closed set of named cases that a value can represent A [nullable value type](nullable-value-types.md) `T?` represents all values of its underlying value type `T` and an additional [null](../keywords/null.md) value. You can't assign `null` to a variable of a value type, unless it's a nullable value type. diff --git a/docs/csharp/language-reference/operators/patterns.md b/docs/csharp/language-reference/operators/patterns.md index b753c3f9d24f7..83c597f0fef6f 100644 --- a/docs/csharp/language-reference/operators/patterns.md +++ b/docs/csharp/language-reference/operators/patterns.md @@ -300,6 +300,30 @@ You can also nest a subpattern within a slice pattern, as the following example For more information, see the [List patterns](~/_csharplang/proposals/csharp-11.0/list-patterns.md) feature proposal note. +## Union patterns + +Beginning with C# 15, when the incoming value of a pattern is a [union type](../builtin-types/union.md), patterns automatically *unwrap* the union—they apply to the union's `Value` property rather than the union value itself. This means the union is transparent to pattern matching: + +```csharp +public record class Cat(string Name); +public record class Dog(string Name); +public union Pet(Cat, Dog); + +string Describe(Pet pet) => pet switch +{ + Dog d => d.Name, + Cat c => c.Name, +}; +``` + +Two patterns are exceptions: the `var` pattern and the discard `_` pattern apply to the union value itself, not its `Value` property. + +The `null` pattern checks whether the union's `Value` is null. For class-based unions, `null` also succeeds when the union reference itself is null. + +When a union type provides the *non-boxing access pattern* (`HasValue` and `TryGetValue` members), the compiler uses those members to avoid boxing value-type cases during pattern matching. + +For more information, see the [Union matching](../builtin-types/union.md#union-matching) section. For the specification, see [Unions](~/_csharplang/proposals/unions.md). + ## C# language specification For more information, see the [Patterns and pattern matching](~/_csharpstandard/standard/patterns.md) section of the [C# language specification](~/_csharpstandard/standard/README.md). diff --git a/docs/csharp/language-reference/operators/switch-expression.md b/docs/csharp/language-reference/operators/switch-expression.md index d86dc324090ae..6e81cb1210e92 100644 --- a/docs/csharp/language-reference/operators/switch-expression.md +++ b/docs/csharp/language-reference/operators/switch-expression.md @@ -47,6 +47,8 @@ The preceding example uses [property patterns](patterns.md#property-pattern) wit If none of a `switch` expression's patterns matches an input value, the runtime throws an exception. In .NET Core 3.0 and later versions, the exception is a . In .NET Framework, the exception is an . In most cases, the compiler generates a warning if a `switch` expression doesn't handle all possible input values. [List patterns](patterns.md#list-patterns) don't generate a warning when all possible inputs aren't handled. +For [union types](../builtin-types/union.md), a `switch` expression is exhaustive when it handles all case types. A catch-all arm isn't needed. If the null state of the union's `Value` property is "maybe null," you must also handle `null` to avoid a warning. For more information, see [Union exhaustiveness](../builtin-types/union.md#union-exhaustiveness). + > [!TIP] > To guarantee that a `switch` expression handles all possible input values, provide a `switch` expression arm with a [discard pattern](patterns.md#discard-pattern). diff --git a/docs/csharp/whats-new/csharp-15.md b/docs/csharp/whats-new/csharp-15.md index 3b0db85177798..6fa4bdb9cb2a5 100644 --- a/docs/csharp/whats-new/csharp-15.md +++ b/docs/csharp/whats-new/csharp-15.md @@ -11,6 +11,7 @@ ai-usage: ai-assisted C# 15 includes the following new features. You can try these features using the latest [Visual Studio 2026](https://visualstudio.microsoft.com/) version or the [.NET 11 preview SDK](https://dotnet.microsoft.com/download/dotnet): - [Collection expression arguments](#collection-expression-arguments) +- [Union types](#union-types) C# 15 is the latest C# release. C# 15 is supported on **.NET 11**. For more information, see [C# language versioning](../language-reference/configure-language-version.md). @@ -41,6 +42,35 @@ HashSet set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", You can learn more about collection expression arguments in the [language reference article on collection expressions](../language-reference/operators/collection-expressions.md#collection-expression-arguments) or the [feature specification](~/_csharplang/proposals/collection-expression-arguments.md). For information on using collection expression arguments in collection initializers, see [Object and Collection Initializers](../programming-guide/classes-and-structs/object-and-collection-initializers.md#collection-expression-arguments). +## Union types + +C# 15 introduces *union types*, which represent a value that can be one of several *case types*. Declare a union with the `union` keyword: + +```csharp +public record class Cat(string Name); +public record class Dog(string Name); +public record class Bird(string Name); + +public union Pet(Cat, Dog, Bird); +``` + +Unions provide implicit conversions from each case type, and the compiler ensures `switch` expressions are exhaustive across all case types: + +```csharp +Pet pet = new Dog("Rex"); + +string name = pet switch +{ + Dog d => d.Name, + Cat c => c.Name, + Bird b => b.Name, +}; +``` + +Union types first appeared in .NET 10 Preview 2. However, the `UnionAttribute` and `IUnion` interface aren't included in the .NET 10 Preview 2 runtime—you must declare them in your project. They'll be included in a future preview. + +For more information, see [Union types](../language-reference/builtin-types/union.md) in the language reference or the [feature specification](~/_csharplang/proposals/unions.md). + +