How to Use C# 15 Union Types in .NET 11 Without Breaking Your JSON APIs
C# 15 union types are now a stable language feature. .NET 11 Release Candidate 1, released on 8 September 2026 with a go-live support licence, makes C# 15 the default language version for projects targeting net11.0. You no longer need <LangVersion>preview</LangVersion>. You declare a closed set of case types with the new union keyword, and the compiler gives you implicit conversions from each case and exhaustiveness checking in switch expressions:
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);
Pet pet = new Dog("Rex");
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
}; // no discard arm neededThat part works as advertised. The problems appear at the boundaries: default struct values, value-type boxing, and above all JSON. By default, System.Text.Json in .NET 11 cannot deserialize a union whose cases are all objects. This article covers the language mechanics you need and the practical rules for using unions in production code.

Version status as of RC1
ItemStatusC# 15 union declarationsStable in RC1, default for net11.0closed class hierarchiesStable in RC1, default for net11.0UnionAttribute / IUnion runtime typesShipped in System.Runtime.CompilerServices since .NET 11 Preview 5System.Text.Json union supportShipping in .NET 11. API details changed during previews and may still move before GA.NET 11 GAPlanned for 10 November 2026.NET 11 supportStandard Term Support (STS), until 9 November 2028
RC1 is the first of two release candidates. Microsoft expects mostly fixes from here, but the serialization story in particular was still changing in late previews. Code the language feature against RC1 with confidence, and check serializer behaviour again on RC2.
To use unions, target .NET 11:
xml
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
</PropertyGroup>Don't try to bring unions to net10.0 by raising LangVersion. Microsoft ties each C# version to a .NET version, and compiler-generated unions depend on runtime types that only ship with .NET 11.
What a union actually compiles to
Understanding the lowering explains most of the edge cases. The Pet declaration above becomes, in effect:
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; }
}Several practical consequences follow from that:
A union is a struct wrapping a single
object?reference. For reference-type cases this costs almost nothing: one reference field, no extra allocation beyond the case object itself.Value-type cases are boxed.
public union IntOrString(int, string);boxes everyintyou store in it.default(Pet)is a valid value whoseValueisnull. This is the most important consequence, and it comes up again below.A union adds no equality, cloning or deconstruction behaviour. Unlike a
record, it only answers the question "which case is this?" Equality and data belong to the case types, so make them records.
A union can have a body with methods and computed properties. It cannot have instance fields, auto-properties, or public single-parameter constructors, because the compiler generates those itself:
csharp
public record class Meters(double Value);
public record class Feet(double Value);
public union Length(Meters, Feet)
{
public double TotalMeters => this switch
{
Meters m => m.Value,
Feet f => f.Value * 0.3048,
_ => throw new InvalidOperationException("The Length has no value."),
};
}That final discard arm is there on purpose. It deals with default(Length), whose Value is null.
Unions can also be generic, which is where they most obviously replace hand-written Result and Option types:
csharp
public record class None;
public record class Some<T>(T Value);
public union Option<T>(None, Some<T>);Pattern matching rules that will surprise you
Patterns test the contents, not the wrapper
When you match on a union, patterns apply to its Value. The union itself is effectively transparent. That is why Dog d works in the first example, and it produces a trap many people will hit:
csharp
Pet pet = new Dog("Rex");
bool isPet = pet is Pet; // typically false: tests the Dog inside, not the wrapperThere are three exceptions: _, var and not apply to the union value itself, not to Value. That matters when you combine patterns:
csharp
// 'not null' tests the Pet? value, not pet.Value,
// so 'var value' below still captures the Pet?
GetPet() switch
{
not null and var value => /* ... */,
_ => /* ... */,
};In code review, pay attention to any union pattern that mixes not, var or _ with type patterns. It rarely does what a quick reading suggests.
Exhaustiveness and the default-value hole
The compiler treats a switch as exhaustive when every case type is handled, provided the union's Value is known to be non-null. The null state of Value is "maybe null" if any case type is nullable, or if the compiler can see the value might be default:
csharp
Pet pet = default;
var description = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
null => "no pet", // required here, or you get a warning
};Flow analysis only protects you where it can see the value. A default union that arrives through a field, a collection, or a deserialized DTO won't produce a warning. It will fail at runtime when no arm matches. Treat unions like any other struct: if a default instance can reach your code, add a null arm.
For unions you get back as nullable (Pet?), the null pattern succeeds both when the Nullable<Pet> has no value and when the inner Value is null.

Unions, closed hierarchies or OneOf
C# 15 adds two exhaustiveness features at once, which invites the obvious question of which one to use. The second is the closed class modifier:
csharp
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;
string Describe(GateState state) => state switch
{
Closed => "closed",
Open(var percent) => $"{percent}% open",
}; // exhaustive: every direct descendant is knownA closed class is implicitly abstract. It can only be derived from inside its own assembly, and it cannot be combined with sealed, static or an explicit abstract. Closure is not transitive: a non-closed descendant can still be subclassed elsewhere, so intermediate types need to be marked closed too if you want exhaustiveness further down the tree.
Here is how they compare in practice:
unionclosed hierarchyOneOf (library)Case types must share a baseNo. Any types, including ones you don't own (int, string, BCL types)Yes, via inheritanceNoShared membersNoneBase class membersNoneExhaustive switchLanguage-levelLanguage-levelVia .Match / .Switch lambdasRepresentationStruct holding object?Normal class hierarchyLibrary struct typesExtensible by consumersNo, recompile to add a caseNot outside the declaring assemblyNoWorks before .NET 11NoNoYes
Guidelines that follow from this:
Use a union when the cases are unrelated types, especially types you don't own, or when you are grouping existing types for a single API return.
Result-style returns,IntOrStringprotocol fields and message envelopes are the typical cases.Use a closed hierarchy when the cases genuinely share state or behaviour and "is-a" describes the relationship. Domain state machines are the typical fit.
Keep OneOf or similar libraries in code that must still build for .NET 8, 9 or 10. .NET 8 and 9 both reach end of support on 10 November 2026, so plenty of teams will spend the next year multi-targeting. Migrating from OneOf to unions is fairly mechanical (replace
.Match(...)lambdas withswitchexpressions), but only oncenet11.0is your lowest target.
A realistic example with minimal API results
Modelling service outcomes is where unions improve ASP.NET Core code most:
csharp
public record class Found<T>(T Value);
public record class NotFound(string Resource, string Id);
public record class Invalid(IReadOnlyList<string> Errors);
public union Lookup<T>(Found<T>, NotFound, Invalid);
public sealed class OrderService(OrderDbContext db)
{
public async Task<Lookup<Order>> FindAsync(string id)
{
if (!Guid.TryParse(id, out var orderId))
return new Invalid(["Order id must be a GUID."]);
var order = await db.Orders.FindAsync(orderId);
return order is null
? new NotFound("order", id)
: new Found<Order>(order);
}
}Each return relies on the implicit union conversion. There is no factory method and no Result.Success(...) wrapper. The endpoint then maps outcomes to HTTP responses in one exhaustive expression:
csharp
app.MapGet("/orders/{id}", async (string id, OrderService orders) =>
(await orders.FindAsync(id)) switch
{
Found<Order> f => Results.Ok(f.Value),
NotFound => Results.NotFound(),
Invalid i => Results.BadRequest(i.Errors),
});If you add a Forbidden case to Lookup<T> next month, every switch that doesn't handle it produces a warning at compile time. That is the main benefit: a new failure mode shows up at every call site instead of being silently mapped to a 500. It only helps if those warnings block the build, so promote the non-exhaustive switch warning to an error in CI.
This pattern also keeps the union inside your code. The HTTP contract is whatever Results.Ok(f.Value) serializes, which is an ordinary Order. Keep that in mind for the next section.
The System.Text.Json trap
In .NET 11, System.Text.Json understands unions, but its defaults were designed for "structural" JSON, not for round-tripping .NET types. It serializes the case value directly, without a type discriminator. It then works out the case on deserialization from the JSON token kind alone.
That is fine when every case maps to a different kind of JSON token:
csharp
public union IntOrString(int, string);
// 42 -> int
// "hello" -> stringIt fails as soon as two cases produce the same token kind. Pet(Cat, Dog) is two JSON objects, and so is almost every domain union you would write. Andrew Lock's testing on Preview 7 showed exactly this failure: serialization succeeds, then deserialization throws a JsonException because the object token is ambiguous between several case types and a custom type classifier is needed. The API review deliberately chose this default, to favour clean interop with TypeScript-style JSON over automatic $type discriminators. Expect it to hold at GA.
Two more sharp edges were open issues on the runtime repository at the time of writing:
Web defaults make even
IntOrStringambiguous.JsonSerializerOptions.Web, which ASP.NET Core uses, setsNumberHandlingto allow reading numbers from strings. Theintcase then also accepts string tokens, so a JSON string is rejected as ambiguous. The textbookIntOrStringexample therefore can't be used as-is as a request body in a default ASP.NET Core app on RC1.Missing properties become
default(union). If a union-typed property is missing from the payload, the serializer builds the containing object with a default union, whoseValueisnull, and doesn't throw. Combined with the exhaustiveness gap described earlier, that means a malformed request can reach aswitchwith nonullarm.
What to do about it
Option 1 — recommended for public APIs: don't put unions on the wire. Map them to explicit DTOs with your own discriminator, just as you would with any internal domain type:
csharp
public sealed record PetDto(string Kind, string Name);
static PetDto ToDto(Pet pet) => pet switch
{
Dog d => new("dog", d.Name),
Cat c => new("cat", c.Name),
Bird b => new("bird", b.Name),
null => throw new InvalidOperationException("Empty Pet."),
};You keep full control over the contract, OpenAPI documents stay readable, and the serializer's union heuristics never come into play.
Option 2 — when the union must be serialized directly: supply a type classifier. During the previews, .NET 11 added a [JsonUnion] attribute that accepts a TypeClassifier type, implemented by deriving from JsonTypeClassifierFactory<T>. The classifier reads ahead in the JSON and returns the case Type:
csharp
[JsonUnion(TypeClassifier = typeof(PetClassifier))]
public union Pet(Cat, Dog, Bird);
public sealed class PetClassifier : JsonTypeClassifierFactory<Pet>
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
return static (ref Utf8JsonReader reader) =>
{
if (reader.TokenType is not JsonTokenType.StartObject) return null;
while (reader.Read() && reader.TokenType is not JsonTokenType.EndObject)
{
if (reader.ValueTextEquals("kind"u8))
{
reader.Read();
if (reader.ValueTextEquals("dog"u8)) return typeof(Dog);
if (reader.ValueTextEquals("cat"u8)) return typeof(Cat);
if (reader.ValueTextEquals("bird"u8)) return typeof(Bird);
return null;
}
reader.Read();
reader.Skip();
}
return null;
};
}
}This shape comes from Preview 7-era samples. Check the exact signatures against the RC2 or GA API before depending on it. Also note that this classifier only works if your case types actually write a kind property. You are rebuilding a discriminator by hand.
A general-purpose JsonUnionTypeStructuralClassifier was merged for RC1. It infers the case from unique (or [JsonRequired]) properties. Early analysis identified limitations: nested unions, cases without a unique property, and mixed POCO and dictionary cases. The API review also raised performance concerns because it reads ahead. Test it against your real payloads before adopting it.
Closed hierarchies behave differently. They use the normal polymorphism machinery ([JsonDerivedType] with $type). .NET 11 adds an InferClosedTypePolymorphism option to infer derived types. In Preview 7 it failed on multi-level hierarchies and on internal derived types, and a runtime issue indicated this should be fixed by GA. Until you have confirmed that on your target build, list derived types explicitly with [JsonDerivedType].
Performance notes
Reference-type cases: the union struct adds one reference-sized field. Matching compiles to type checks on
Value, with nothing hidden behind it.Value-type cases box. For hot paths, such as a union of
int,longanddoublein a parser, write a custom union that implements the non-boxing access pattern. Mark a struct[Union], implementIUnion, store the cases in typed fields with a tag, and add aHasValueproperty plus oneTryGetValue(out T)per case. The compiler then callsTryGetValuefor type patterns andHasValuefor null patterns, so it never boxes throughValue.Custom unions can also be classes. Mark a class
[Union]when you need reference semantics or inheritance. For class-based unions,nullmatches both a null reference and a nullValue.
Measure before you hand-roll anything. For most application code, including the minimal API example above, the generated struct is the right default.
Common mistakes to avoid
Testing
x is MyUnion. Patterns apply to the contents, so this checks the wrong thing. Usex.Value is nullor avarpattern if you need the wrapper itself.Leaving out the
nullarm where defaults can occur. Anything deserialized, stored in arrays, or used as a struct field can bedefault.Exposing unions directly in public JSON contracts. Map them to DTOs unless every case uses a different JSON token kind and you have tested with
JsonSerializerOptions.Web.Using a union where the cases share behaviour. If every case needs
NameandValidate(), a closed hierarchy with a base class is the better fit.Raising
LangVersionon older frameworks. It is unsupported, and generated unions need .NET 11 runtime types.
What to watch before and after GA
Between now and 10 November, RC2 is the build to test against, particularly for System.Text.Json union handling and InferClosedTypePolymorphism, where fixes were still landing late in the cycle. The C# design proposals also list features that did not ship in C# 15. The documentation describes the current feature set as not covering the whole unions specification, so expect more union-related features in later releases rather than assuming everything in the spec is available today.
For teams on .NET 8 or 9 facing the November end-of-support date, the practical path is: move to .NET 10 LTS or .NET 11 STS for support reasons, keep libraries such as OneOf in anything that still multi-targets, and introduce union types in new code that targets only net11.0. There, a union return type and an exhaustive switch replace a whole category of convention-based error handling with checks the compiler enforces.


