Skip to content
.N
Startups // September 23, 2026 · 13 min read

How to Use C# 15 Union Types in .NET 11 Without Breaking Your JSON APIs

LM
Liam Mercer
// contributor
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 needed

That 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.

How to Use C# 15 Union Types in .NET 11 Without Breaking Your JSON APIs

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 every int you store in it.

  • default(Pet) is a valid value whose Value is null. 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 wrapper

There 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.

How to Use C# 15 Union Types in .NET 11 Without Breaking Your JSON APIs

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 known

A 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, IntOrString protocol 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 with switch expressions), but only once net11.0 is 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" -> string

It 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 IntOrString ambiguous. JsonSerializerOptions.Web, which ASP.NET Core uses, sets NumberHandling to allow reading numbers from strings. The int case then also accepts string tokens, so a JSON string is rejected as ambiguous. The textbook IntOrString example 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, whose Value is null, and doesn't throw. Combined with the exhaustiveness gap described earlier, that means a malformed request can reach a switch with no null arm.

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, long and double in a parser, write a custom union that implements the non-boxing access pattern. Mark a struct [Union], implement IUnion, store the cases in typed fields with a tag, and add a HasValue property plus one TryGetValue(out T) per case. The compiler then calls TryGetValue for type patterns and HasValue for null patterns, so it never boxes through Value.

  • Custom unions can also be classes. Mark a class [Union] when you need reference semantics or inheritance. For class-based unions, null matches both a null reference and a null Value.

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

  1. Testing x is MyUnion. Patterns apply to the contents, so this checks the wrong thing. Use x.Value is null or a var pattern if you need the wrapper itself.

  2. Leaving out the null arm where defaults can occur. Anything deserialized, stored in arrays, or used as a struct field can be default.

  3. 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.

  4. Using a union where the cases share behaviour. If every case needs Name and Validate(), a closed hierarchy with a base class is the better fit.

  5. Raising LangVersion on 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.

More from Dot Net Masters