Sick Gaming
[DevBlog MS] Use C# unions and closed hierarchies in ASP.NET Core - Printable Version

+- Sick Gaming (https://sickgaming.net)
+-- Forum: Programming (https://sickgaming.net/forum-76.html)
+--- Forum: C#, Visual Basic, & .Net Frameworks (https://sickgaming.net/forum-79.html)
+--- Thread: [DevBlog MS] Use C# unions and closed hierarchies in ASP.NET Core (/thread-113168.html)



[DevBlog MS] Use C# unions and closed hierarchies in ASP.NET Core - xSicKxBot - 09-19-2026

Sometimes an API contract says that a value can have more than one JSON type. Kubernetes has a practical example:
Code:
maxUnavailable
can be an absolute number, such as
Code:
2
, or a percentage, such as
Code:
"25%"
. Imagine an ASP.NET Core endpoint that exposes the same contract:

Code:
[code]public union IntOrString(int, string); app.MapGet("/deployments/{name}/max-unavailable", IntOrString (string name) => Deployments.GetMaxUnavailable(name));[/code]

Depending on the deployment, the response is either
Code:
2
or
Code:
"25%"
.

Code:
IntOrString
uses the native
Code:
union
declaration introduced in C# 15 and .NET 11. A union is one named type that represents a value from a fixed list of case types. This union accepts an
Code:
int
or a
Code:
string
, but not a
Code:
bool
, a
Code:
DateTime
, or anything else. Union cases aren’t limited to classes in one hierarchy; they can include primitives, classes, interfaces, and nullable types.

Each case converts to the union directly without any casting:

Code:
[code]IntOrString absolute = 2; IntOrString percentage = "25%";[/code]

It is also quite natural to use a
Code:
switch
statement or expression to handle the active case. Normal C# pattern matching works:

Code:
[code]static string Describe(IntOrString value) => value switch { int count => $"{count} pods", string percentage => percentage, };[/code]

Notice that there is no fallback arm such as
Code:
_ => ...
. The compiler knows every permitted case, so it checks that the
Code:
switch
handles them all. If another case is added to
Code:
IntOrString
, existing switches that don’t handle it produce a warning.

For example, suppose a
Code:
string
case is added to a union that previously contained only
Code:
bool
and
Code:
decimal
, but its formatter isn’t updated:

Code:
[code]public union SettingValue(bool, decimal, string); static string Describe(SettingValue value) => value switch { bool enabled => enabled ? "enabled" : "disabled", decimal number => number.ToString(), // missing string case };[/code]

The compiler identifies the missing case:

Code:
[code]warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'string' is not covered.[/code]

This feedback appears wherever the union is handled, so adding a case can’t silently leave an existing switch incomplete. That’s one of the main benefits of using a union instead of
Code:
object
or an open hierarchy.

Before native unions, developers usually reached for
Code:
object
, a shared base type, or a custom wrapper.
Code:
object
accepts too much, and a base type can’t group unrelated existing types such as
Code:
int
and
Code:
string
. A custom wrapper can enforce the set, but it also needs its own construction and matching APIs. A native union keeps the contract in the method signature, works with regular C# patterns, and has built-in support in
Code:
System.Text.Json
(STJ).

Other ways to model alternatives

Before choosing a union, it helps to separate it from two related features.

Polymorphism

Regular C# polymorphism models related types through inheritance. For example,
Code:
Circle
and
Code:
Square
can derive from a common
Code:
Shape
base class and share its members and behavior.

STJ has been able to serialize such a hierarchy with a discriminator. Mark the base type with
Code:
[JsonPolymorphic]
and register each supported derived type with
Code:
[JsonDerivedType]
. The resulting JSON identifies the active type explicitly (see
Code:
$type
entry in the JSON):

Code:
[code]{"$type":"circle","radius":5}[/code]

STJ always works from that explicitly registered set; it doesn’t automatically include every type that might derive from the base in the future. If the C# base class remains open, the language doesn’t enforce the same set and a
Code:
switch
over it needs a fallback case.

Closed hierarchies

C# 15 adds support for closed class hierarchies. Adding the
Code:
closed
keyword to a base class prevents other assemblies from deriving directly from it. The compiler can then treat its known derived types as a complete list and check a
Code:
switch
for exhaustiveness:

Code:
[code]public closed record class PaymentEvent(string PaymentId); public sealed record class PaymentInitiated(string PaymentId) : PaymentEvent(PaymentId); public sealed record class PaymentAuthorized(string PaymentId, decimal Amount) : PaymentEvent(PaymentId); public sealed record class PaymentFailed(string PaymentId, string Reason) : PaymentEvent(PaymentId);[/code]

A closed hierarchy is union-like because it represents a known set of alternatives. In C#, however, it is still an inheritance hierarchy: its cases derive from a base class and can share members and behavior. When you control the hierarchy,
Code:
closed
enforces the fixed set in the language and lets STJ infer the derived types instead of registering each one explicitly.

The
Code:
closed
modifier affects the C# type relationship; it doesn’t change JSON by itself. The serialization section below shows the default behavior first, followed by the opt-in polymorphic behavior.

Choosing a model for your API

A union isn’t always the best choice whenever an API has several possible shapes. Start with whether you control the case types and the JSON contract.

When you are designing a new API and all alternatives are classes you control, prefer a closed hierarchy with a JSON discriminator. For example,
Code:
PaymentInitiated
,
Code:
PaymentAuthorized
, and
Code:
PaymentFailed
can derive from
Code:
PaymentEvent
. The discriminator makes the JSON self-describing, while
Code:
closed
lets the compiler check that every known event is handled.

Keep the base class open only when the model must remain extensible, such as an existing hierarchy designed for other assemblies to extend. STJ still requires every supported derived type to be registered explicitly, and callers need a fallback because the compiler can’t consider an open hierarchy exhaustive.

Choose a union when you must preserve an established discriminator-free contract, or when the cases can’t derive from one base class. This includes primitives and existing types you don’t control. A union preserves each case’s existing JSON shape and still gives callers a fixed set of types to handle.

That discriminator-free format is both a benefit and a tradeoff. Writing the active union case is straightforward. When reading, two cases can look the same in JSON and require explicit classification. For a new polymorphic contract you control, a closed hierarchy with a discriminator avoids that ambiguity.

Also consider how the contract will evolve. Adding a union case or a derived type to a closed hierarchy can produce warnings in existing exhaustive switches, immediately showing callers what they need to handle. An open hierarchy avoids that coupling by requiring a fallback from the start.

Closed-hierarchy serialization uses the same STJ polymorphism infrastructure described above. The
Code:
closed
modifier adds language-level guardrails, and
Code:
InferClosedTypePolymorphism
lets STJ discover the derived types from those guardrails.

ASP.NET Core union support comes from STJ. Unions therefore work where ASP.NET Core uses STJ: JSON request and response bodies, SignalR‘s
Code:
JsonHubProtocol
, and Blazor‘s JavaScript interop, persisted component state, and prerendered component parameters. They aren’t supported for query strings, route values, headers, or form fields. For more information, see Limitations.

Serializing and deserializing unions

A union type is declared with the
Code:
union
keyword and a list of case types. The examples throughout this article use the following declarations:

Code:
[code]public union UnionIntString(int, string); public union UnionBoolString(bool, string); public union UnionNullableIntString(int?, string); public record Cat(string Name, string Coat); public record Dog(string Name, string Breed); public union UnionPet(Cat, Dog);[/code]

STJ serializes a union value as its active case, with nothing added to represent the union itself. The union wrapper is unpacked and only the active case is written, using that case’s own JSON contract. There’s no envelope object, no
Code:
$type
field, and no discriminator of any kind:

Code:
[code]JsonSerializer.Serialize(new UnionIntString(42)); // 42 JsonSerializer.Serialize(new UnionIntString("hello")); // "hello" JsonSerializer.Serialize(new UnionPet(new Cat("Whiskers", "Tabby"))); // { "name": "Whiskers", "coat": "Tabby" }[/code]

STJ can select a union case automatically when the cases use different JSON types. For example,
Code:
UnionBoolString(bool, string)
is unambiguous because a JSON boolean maps to
Code:
bool
and a JSON string maps to
Code:
string
.

When multiple cases could match the same JSON value, STJ needs help choosing one.
Code:
UnionPet(Cat, Dog)
is ambiguous because both cases are JSON objects. If the payload follows an established discriminator-free contract that can’t be changed, the built-in structural classifier can distinguish object cases by their property names:

Code:
[code][JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] public union UnionPet(Cat, Dog);[/code]

Structural classification tradeoffs

The structural classifier must scan the JSON object before STJ can deserialize it, so classification adds work proportional to the payload size. Its decision depends on the case property names, which means changing those shapes can change how existing payloads are classified or make them ambiguous. Prefer a closed hierarchy with a discriminator for new contracts you control.

If the cases can’t be distinguished by structure, an advanced scenario can supply a custom
Code:
JsonTypeClassifier
.

The opening
Code:
IntOrString
endpoint is an output example. When that union is used as an HTTP request body, the web JSON settings also allow numbers to be read from strings, so a JSON string could match either case. Deserializing that contract requires explicit custom classification.

For an overview of STJ’s union support and classifier APIs, see What’s new in .NET libraries for .NET 11.

Serializing and deserializing closed hierarchies

The
Code:
closed
modifier doesn’t require polymorphic serialization. When code uses a concrete derived type, STJ serializes and deserializes it like any other type and doesn’t add a discriminator:

Code:
[code]var json = JsonSerializer.Serialize(new PaymentAuthorized("p-123", 42.5m), JsonSerializerOptions.Web); var payment = JsonSerializer.Deserialize<PaymentAuthorized>(json, JsonSerializerOptions.Web);[/code]

Code:
[code]{"paymentId":"p-123","amount":42.5}[/code]

This round trip works because both the writer and reader use the concrete
Code:
PaymentAuthorized
type. If an API instead uses the
Code:
PaymentEvent
base type, the JSON needs to identify which derived type to create. Polymorphic serialization can be enabled on the closed base type:

Code:
[code][JsonPolymorphic(InferClosedTypePolymorphism = true)] public closed record class PaymentEvent(string PaymentId);[/code]

STJ then infers the derived types in the closed hierarchy and uses their type names as discriminators. An endpoint can deserialize a
Code:
PaymentEvent
request and serialize the active derived type back:

Code:
[code]app.MapPost("/payment-event", (PaymentEvent paymentEvent) => paymentEvent);[/code]

This JSON is deserialized as
Code:
PaymentAuthorized
and serialized with the same discriminator:

Code:
[code]{"$type":"PaymentAuthorized","paymentId":"p-123","amount":42.5}[/code]

The opt-in can instead be applied to a JSON pipeline with
Code:
JsonSerializerOptions.InferClosedTypePolymorphism
. The following sections use unions in their examples, but configured closed hierarchies flow through the same STJ-backed Minimal API, MVC, SignalR, and Blazor paths.

Minimal APIs

Unions work as request body parameters and as return types in both the runtime path (
Code:
RequestDelegateFactory
) and the source-generated Request Delegate Generator (RDG). Behavior is identical across both.

Code:
[code]var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); // Request body: UnionBoolString is unambiguous (bool vs string), so it binds without a classifier. app.MapPost("/flag", (UnionBoolString flag) => flag); // Request body: UnionPet's cases are both objects, so it uses the built-in classifier shown earlier. app.MapPost("/pet", ([FromBody] UnionPet pet) => TypedResults.Ok(pet)); // Return types: only the active case is serialized, and no classifier is needed to write. app.MapGet("/value", () => new UnionIntString(42)); app.MapGet("/pet", () => new UnionPet(new Cat("Whiskers", "Tabby"))); app.Run();[/code]

Unions compose with the usual Minimal API return types. For example, a union can be returned asynchronously, wrapped in a nullable, or handed back through
Code:
TypedResults
:

Code:
[code]app.MapGet("/maybe", () => new UnionNullableIntString((int?)null)); app.MapGet("/typed", () => TypedResults.Ok(new UnionPet(new Cat("Whiskers", "Tabby"))));[/code]

A union can also be a property of another model, an item streamed from an
Code:
IAsyncEnumerable<T>
, or the body slot of an
Code:
[AsParameters]
container. Union serialization also respects options configured through
Code:
ConfigureHttpJsonOptions
.

MVC controllers

Unions flow through the STJ input and output formatters, so controllers support them as action parameters and return types, including
Code:
Task<TUnion>
and
Code:
ValueTask<TUnion>
results:

Code:
[code][ApiController] [Route("[controller]/[action]")] [Produces("application/json")] public class PetsController : ControllerBase { [HttpPost] public UnionBoolString Echo([FromBody] UnionBoolString value) => value; [HttpGet("{kind}")] public UnionIntString Primitive(string kind) => kind switch { "value" => new UnionIntString(42), _ => new UnionIntString("hi"), }; }[/code]

Union serialization and deserialization follow the rules described earlier. Controllers use
Code:
JsonSerializerDefaults.Web
just like Minimal APIs, so the
Code:
IntOrString
input caveat in that section applies to controller actions too.

SignalR

Code:
JsonHubProtocol
forwards reads and writes to
Code:
System.Text.Json
, so unions work as hub method parameters, return values, and stream items without any extra configuration:

Code:
[code]public class ChatHub : Hub { // Union argument (client → server). public Task Send(UnionIntString message) => Clients.All.SendAsync("Receive", message); // Union return value (server → client). public UnionPet GetPet() => new UnionPet(new Cat("Whiskers", "Tabby")); // Union stream items (server → client). public async IAsyncEnumerable<UnionIntString> Stream() { yield return 1; yield return "two"; } }[/code]

On the read path, the parameter, return, or stream-item
Code:
Type
resolved from the invocation binder drives the union converter, including any
Code:
[JsonUnion]
classifier.

Unlike HTTP JSON binding in Minimal APIs and MVC,
Code:
JsonHubProtocol
doesn’t treat a JSON
Code:
String
token as ambiguous for numeric cases, so a union such as
Code:
UnionIntString(int, string)
round-trips both the
Code:
int
and
Code:
string
cases without a classifier. Unions whose cases share the
Code:
StartObject
token, such as
Code:
UnionPet(Cat, Dog)
, are still ambiguous on read and require a classifier.

Unions are supported only with
Code:
JsonHubProtocol
. The MessagePack and Newtonsoft.Json hub protocols don’t support unions, because their underlying serializers have no union support.

Blazor

Blazor works with unions in two ways, depending on whether a value stays in-process or crosses a serialization boundary. In-process component parameters are assigned directly and need no serialization, while JavaScript interop, persisted component state, and prerendered parameters serialize unions with
Code:
System.Text.Json
and follow the same rules described earlier in this article.

Component parameters

A component parameter is set by direct assignment when a component is rendered from Razor markup or through
Code:
RenderTreeBuilder.AddComponentParameter
. In-process rendering doesn’t serialize parameters, so a union parameter works with no extra configuration:

Code:
[code]<PetCard Pet="@(new UnionPet(new Cat("Whiskers", "Tabby")))" />[/code]

Code:
[code]public class PetCard : ComponentBase { [Parameter] public UnionPet Pet { get; set; } }[/code]

JavaScript interop

JavaScript interop through
Code:
IJSRuntime
serializes arguments and return values with
Code:
System.Text.Json
. This is useful when a JavaScript API already accepts a union-shaped contract. For example,
Code:
Element.scrollIntoView
accepts either a Boolean alignment shorthand or an options object:

Code:
[code]public sealed record ScrollIntoViewOptions(string Behavior, string Block, string Inline); public union ScrollIntoViewArgument(bool, ScrollIntoViewOptions); private ValueTask ScrollAsync( ElementReference element, ScrollIntoViewArgument argument) => JS.InvokeVoidAsync("scrollElementIntoView", element, argument); await ScrollAsync(target, false); await ScrollAsync(target, new ScrollIntoViewOptions("instant", "start", "nearest"));[/code]

Code:
[code]window.scrollElementIntoView = (element, argument) => element.scrollIntoView(argument);[/code]

The active union case is serialized using the representation expected by JavaScript: either a JSON Boolean or an options object. No union envelope or discriminator is added.

Persisted component state

Code:
PersistentComponentState
serializes state with
Code:
System.Text.Json
, so a union round-trips through
Code:
PersistAsJson
and
Code:
TryTakeFromJson
, including a union whose active case is
Code:
null
.

Prerendering

When a component is prerendered with Blazor Server or Blazor WebAssembly, its parameters are serialized into the component marker with
Code:
System.Text.Json
and deserialized when the component initializes on the client. Unions are supported across this boundary, including a union whose active case serializes to JSON
Code:
null
, such as a
Code:
UnionNullableIntString
that holds a null
Code:
int?
.

OpenAPI

The OpenAPI document represents a union as an
Code:
anyOf
schema, with one entry per case type:

Code:
[code]"Cat": { "type": "object", "properties": { "name": { "type": "string" }, "coat": { "type": "string" } } }, "Dog": { "type": "object", "properties": { "name": { "type": "string" }, "breed": { "type": "string" } } }, "UnionIntString": { "anyOf": [ { "type": "integer", "format": "int32" }, { "type": "string" } ] }, "UnionPet": { "type": "object", "anyOf": [ { "$ref": "#/components/schemas/Cat" }, { "$ref": "#/components/schemas/Dog" } ] }[/code]

Because a union case has no discriminator and is structurally identical to the standalone type, each case schema reuses the standalone component name. The
Code:
Cat
and
Code:
Dog
schemas referenced by
Code:
UnionPet
are the same components that a standalone
Code:
Cat
or
Code:
Dog
endpoint produces. This differs from polymorphic types, whose derived schemas are lifted to prefixed component names because they carry a
Code:
$type
discriminator.

An endpoint can also produce multiple response types for the same status code and content type. The
Code:
Microsoft.AspNetCore.Mvc.ApiExplorer
namespace preserves every declared response type, and the generated document emits an
Code:
anyOf
schema when several types share a content type:

Code:
[code]public record Tyrannosaurus(string Name, double BiteForceNewtons); public record Triceratops(string Name, int HornCount); public record Velociraptor(string Name, double TopSpeedKmh); public union UnionDinosaur(Tyrannosaurus, Triceratops, Velociraptor); app.MapGet("/any-of", () => Results.Ok()) .Produces<UnionPet>(StatusCodes.Status200OK, "application/json") .Produces<UnionDinosaur>(StatusCodes.Status200OK, "application/json");[/code]

The same support applies to MVC controllers that declare multiple
Code:
ProducesResponseTypeAttribute
attributes for one status code and content type.

Limitations

Union support requires
Code:
System.Text.Json
. Binding sources that don’t route through STJ don’t support unions:
  • Query string values

  • Route values

  • Header values

  • Form fields


These sources bind a string token to a target type without JSON parsing, so there’s no reliable way to choose a union case. A single query value such as
Code:
?id=42
provides no way to know whether to bind
Code:
int
,
Code:
string
,
Code:
Guid
, or another case. Because of this ambiguity, unions are intentionally not supported in these binding sources.

In Blazor, the same limitation applies to component parameters supplied from non-body sources, including
Code:
[SupplyParameterFromQuery]
and form binding with
Code:
[SupplyParameterFromForm]
. These bind string or form values without JSON parsing, so they don’t support unions.

Parameter binding for unions from non-body sources is still being explored. If you have a scenario that needs it, the team is gathering feedback on the union parameter binding issue.

Summary

The two opening examples make the choice concrete. Kubernetes
Code:
maxUnavailable
fits a union because it is an existing discriminator-free contract that accepts either an
Code:
int
or a
Code:
string
.
Code:
PaymentEvent
fits a closed hierarchy because its cases form one related family and the JSON can identify each event with a discriminator.

Both models let the compiler check exhaustive switches. Choose between them based on the relationship between the alternatives and the JSON contract you need to preserve.

Additional resources

The post Use C# unions and closed hierarchies in ASP.NET Core appeared first on .NET Blog.