Union Types in C# 15: Values From a Closed Set of Types
Objective
A union declaration, new in C# 15, names a type whose value is exactly one of a
closed list of case types: union Pet(Cat, Dog, Bird);. The compiler gives it an
implicit conversion from each case type and knows the list, so a switch that
handles every case type is exhaustive without a _ arm, and a missing one is a
warning. Before C# 15 the same idea took an abstract base class, a marker
interface, or a library such as OneOf. This concept covers what the declaration
generates, how patterns read a union, what default does, the diagnostics, the
hand-written union that avoids boxing, System.Text.Json support, and what happens
on older target frameworks. Everything was compiled and run on the 11.0.100 SDK.
Use Cases
- A method result that is one of several unrelated types, such as a payment that
is
Approved,DeclinedorPending, where the caller must handle each. - Message or command dispatch over a fixed set of types that share no base class and that you may not control.
- Replacing a hand-rolled
Result<T>or a OneOf generic with a type the compiler checks (seeresult-types-versus-exceptions). - A JSON body that can be a number or a string, mapped to one C# type.
Deep Dive
What a declaration generates
csharppublic record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);Reflection on a union confirms the shape. typeof(Pet).IsValueType is true,
the type carries UnionAttribute and implements IUnion, and it has one public
constructor per case type plus a Value property of type object?. Case types
can be classes, structs, interfaces, nullable types, type parameters or other
unions (union Option<T>(None, Some<T>) is legal). A union is not a record: it
adds no equality, cloning or deconstruction, and it adds no data of its own. It
only composes types that already exist.
csharpPet p = new Dog("Rex"); // implicit conversion calls the Dog constructor
Console.WriteLine(p.Value); // Dog { Name = Rex }When a case type is a value type, the value is boxed into the object?:
union IntOrString(int, string) stores the int as a boxed object, and
IntOrString x = 42; x.Value.GetType() prints System.Int32 read back from that
object. If two case types could both accept the source value, the compiler picks
the best conversion by the usual rules: with union U(int, long), U u = 5;
stores an Int32 and U v = 5L; stores an Int64. When neither is better, it is
an error: union U(IFormattable, IComparable) with U u = 5; is
CS0457: Ambiguous user defined conversions.
A declaration may have a body with methods and computed properties, but not
instance fields, auto-properties or field-like events (CS9373), and not an
explicit public constructor with one parameter, because the compiler reserves
those as creation members (CS9374).
Patterns test the contents
The most important rule: on a union, a type or declaration pattern tests the
contents, not the union value. Dog d asks whether Value is a Dog.
csharpstring name = p switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
};A recursive pattern with a type (Dog { Name: "Rex" }) tests the contents and
then looks inside the Dog. A recursive pattern without a type uses the union's
own members, a var or discard pattern captures the union itself, and null
tests the contents. That is why p is Pet does not compile (CS8121): none of
the case types is Pet. And for the default value, the two natural tests
disagree:
csharpPet empty = default;
Console.WriteLine(empty is not null); // False: the contents are null
Console.WriteLine(empty is { }); // True: the union struct itself existsUse is not null for "has a value", and a null arm in a switch.
Exhaustiveness and the default value
Handling every case type needs no discard. Leaving one out gives the warning you already know from enums, with the missing case named:
csharpConsole.WriteLine(p switch { Dog d => 1, Cat c => 2 });
// warning CS8509: ... the pattern 'Bird' is not covered.A discard or var arm hides that warning for the day someone adds a fourth case
type, so prefer a null arm when you need to handle empty contents. Null is
where unions have a gap: default(Pet) and any array element or field of type
Pet that was never assigned hold a null Value. For Pet, whose case types
are all non-nullable, the compiler tracks the contents as "not null", so omitting
the null arm gives no warning, and the switch throws at runtime:
csharpPet p = default;
p switch { Dog d => 1, Cat c => 2, Bird b => 3 };
// SwitchExpressionException: Non-exhaustive switch expression failed to match
// its input. Unmatched value was Pet.With a nullable case type, union NP(Cat?, Dog), the contents are "maybe null"
and omitting the null arm is CS8655, a warning that the lab turns into an
error. The rule of thumb: handle null whenever a default value can reach the
switch, which is always for struct unions in arrays and fields.
A union as a hand-written type, without boxing
The generated union is always a struct and always boxes value-type cases. When
that matters, write the union yourself. A type is a union type if it has
[Union], one public constructor per case type (single parameter), and a public
Value property of type object?. The optional non-boxing access pattern adds
HasValue and a TryGetValue(out T) per case type, and then the compiler matches
types through TryGetValue instead of reading Value:
csharp[System.Runtime.CompilerServices.Union]
public struct IntOrText : System.Runtime.CompilerServices.IUnion
{
private readonly int _int;
private readonly string? _text;
private readonly byte _tag; // 0 = none, 1 = int, 2 = text
public IntOrText(int value) { _int = value; _tag = 1; }
public IntOrText(string? value) { if (value is not null) { _text = value; _tag = 2; } }
public object? Value => _tag switch { 1 => _int, 2 => _text, _ => null };
public bool HasValue => _tag != 0;
public bool TryGetValue(out int value) { value = _int; return _tag == 1; }
public bool TryGetValue(out string value) { value = _text!; return _tag == 2; }
}In the lab, a switch over a thousand of these (int i => i, string s => s.Length, null => 0) allocated 0 bytes after warm-up, measured with
GC.GetAllocatedBytesForCurrentThread. The contract for a custom union: Value
returns null or one of the case types, a value built from a case type reports
that type, and HasValue and TryGetValue agree with Value. The compiler
trusts you on these. The same attribute also allows class-based unions, and a
nested IUnionMembers interface (a "union member provider") when constructors
must stay private; both are described in the language reference.
System.Text.Json
The .NET 11 serializer treats a union declaration as a union contract. It writes
the case value with no envelope, and reads it back by the JSON token type:
csharpIntOrString n = 42, s = "hi";
JsonSerializer.Serialize(n); // 42
JsonSerializer.Serialize(s); // "hi"
JsonSerializer.Deserialize<IntOrString>("7"); // Value is the int 7
JsonSerializer.Serialize<IntOrString>(default); // null
JsonSerializer.Deserialize<IntOrString>("true"); // JsonException: no case type fitsThe limit is cases that share a JSON shape. Pet has three object-shaped cases,
so JsonSerializer.Deserialize<Pet>("{\"Name\":\"R\"}") throws
JsonException: JSON value type 'Object' is ambiguous for union type 'Pet' because multiple case types can use this value type. Specify a custom type classifier to support deserialization. A TypeClassifier on [JsonUnion] is how you resolve
it; for new contracts with several object-shaped cases, a closed hierarchy with
a discriminator is usually the better fit (see
closed-classes-and-exhaustive-hierarchies). I did not run the ASP.NET Core
integration; the ASP.NET team's post says unions work as bodies in Minimal APIs,
MVC and SignalR, but not in query strings, routes, headers or forms.
Older target frameworks
A union needs UnionAttribute and IUnion from System.Runtime.CompilerServices,
and the .NET 11 runtime ships them. On net10.0 the same declaration is
CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined
plus CS0656 for the attribute constructor. Declaring both types in your project
(an attribute class that allows classes and structs, and interface IUnion { object? Value { get; } })
made it compile on net10.0; I compiled that but did not run it on a .NET 10
runtime. On C# 14 (LangVersion 14) there is no workaround: union is a syntax
error. Whether the serializer recognizes a polyfilled attribute is something to
test before you rely on it.
Trade-offs
- Boxing for value-type cases. The generated struct stores an
object?, sounion IntOrString(int, string)allocates for everyint. For hot paths write the union by hand withTryGetValue, or use a class hierarchy. defaultis a hole. A struct union that was never assigned holdsnulland can slip past the compiler. Always add anullarm.- Adding a case type is a breaking change for consumers. Every
switchover a public union becomes non-exhaustive (warning CS8509) when you add a case. That is why a union is for sets you own and can version. - Unions versus a closed hierarchy. Unions group types that need no common
base, including ones you do not own (
int,string). Aclosedclass makes a hierarchy exhaustive when you do own the base. Use the union for the first case and the hierarchy for the second. - Unions versus exceptions and
Trymethods. A union result is explicit but makes callers write aswitch. Use it where failure is an expected outcome, not for programming errors. - Ecosystem coverage. System.Text.Json knows unions in .NET 11. For an ORM, a database driver or a mapper, check support before putting a union in a model.