{"id":5049,"date":"2026-09-10T17:35:28","date_gmt":"2026-09-10T17:35:28","guid":{"rendered":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/09\/10\/use-c-unions-and-closed-hierarchies-in-asp-net-core\/"},"modified":"2026-09-10T17:35:28","modified_gmt":"2026-09-10T17:35:28","slug":"use-c-unions-and-closed-hierarchies-in-asp-net-core","status":"publish","type":"post","link":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/09\/10\/use-c-unions-and-closed-hierarchies-in-asp-net-core\/","title":{"rendered":"Use C# unions and closed hierarchies in ASP.NET Core"},"content":{"rendered":"<p>Sometimes an API contract says that a value can have more than one JSON type. Kubernetes has a practical example: <a href=\"https:\/\/kubernetes.io\/docs\/concepts\/workloads\/controllers\/deployment\/#max-unavailable\"><code>maxUnavailable<\/code><\/a> can be an absolute number, such as <code>2<\/code>, or a percentage, such as <code>\"25%\"<\/code>. Imagine an ASP.NET Core endpoint that exposes the same contract:<\/p>\n<pre><code class=\"language-csharp\">public union IntOrString(int, string);\n\napp.MapGet(\"\/deployments\/{name}\/max-unavailable\", IntOrString (string name) =&gt; Deployments.GetMaxUnavailable(name));<\/code><\/pre>\n<p>Depending on the deployment, the response is either <code>2<\/code> or <code>\"25%\"<\/code>.<\/p>\n<p><code>IntOrString<\/code> uses the native <code>union<\/code> 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<\/code> or a <code>string<\/code>, but not a <code>bool<\/code>, a <code>DateTime<\/code>, or anything else. Union cases aren\u2019t limited to classes in one hierarchy; they can include primitives, classes, interfaces, and nullable types.<\/p>\n<p>Each case converts to the union directly without any casting:<\/p>\n<pre><code class=\"language-csharp\">IntOrString absolute = 2;\nIntOrString percentage = \"25%\";<\/code><\/pre>\n<p>It is also quite natural to use a <code>switch<\/code> statement or expression to handle the active case. Normal C# pattern matching works:<\/p>\n<pre><code class=\"language-csharp\">static string Describe(IntOrString value) =&gt; value switch\n{\n    int count =&gt; $\"{count} pods\",\n    string percentage =&gt; percentage,\n};<\/code><\/pre>\n<p>Notice that there is no fallback arm such as <code>_ =&gt; ...<\/code>. The compiler knows every permitted case, so it checks that the <code>switch<\/code> handles them all. If another case is added to <code>IntOrString<\/code>, existing switches that don\u2019t handle it produce a warning.<\/p>\n<p>For example, suppose a <code>string<\/code> case is added to a union that previously contained only <code>bool<\/code> and <code>decimal<\/code>, but its formatter isn\u2019t updated:<\/p>\n<pre><code class=\"language-csharp\">public union SettingValue(bool, decimal, string);\n\nstatic string Describe(SettingValue value) =&gt; value switch\n{\n    bool enabled =&gt; enabled ? \"enabled\" : \"disabled\",\n    decimal number =&gt; number.ToString(),\n    \/\/ missing string case\n};<\/code><\/pre>\n<p>The compiler identifies the missing case:<\/p>\n<pre><code class=\"language-text\">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><\/pre>\n<p>This feedback appears wherever the union is handled, so adding a case can\u2019t silently leave an existing switch incomplete. That\u2019s one of the main benefits of using a union instead of <code>object<\/code> or an open hierarchy.<\/p>\n<p>Before native unions, developers usually reached for <code>object<\/code>, a shared base type, or a custom wrapper. <code>object<\/code> accepts too much, and a base type can\u2019t group unrelated existing types such as <code>int<\/code> and <code>string<\/code>. 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 <a href=\"https:\/\/learn.microsoft.com\/dotnet\/standard\/serialization\/system-text-json\/overview\"><code>System.Text.Json<\/code><\/a> (STJ).<\/p>\n<h2>Other ways to model alternatives<\/h2>\n<p>Before choosing a union, it helps to separate it from two related features.<\/p>\n<h3>Polymorphism<\/h3>\n<p>Regular C# polymorphism models related types through inheritance. For example, <code>Circle<\/code> and <code>Square<\/code> can derive from a common <code>Shape<\/code> base class and share its members and behavior.<\/p>\n<p>STJ has been able to serialize such a hierarchy with a discriminator. Mark the base type with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.text.json.serialization.jsonpolymorphicattribute\"><code>[JsonPolymorphic]<\/code><\/a> and register each supported derived type with <code>[JsonDerivedType]<\/code>. The resulting JSON identifies the active type explicitly (see <code>$type<\/code> entry in the JSON):<\/p>\n<pre><code class=\"language-json\">{\"$type\":\"circle\",\"radius\":5}<\/code><\/pre>\n<p>STJ always works from that explicitly registered set; it doesn\u2019t automatically include every type that might derive from the base in the future. If the C# base class remains open, the language doesn\u2019t enforce the same set and a <code>switch<\/code> over it needs a fallback case.<\/p>\n<h3>Closed hierarchies<\/h3>\n<p>C# 15 adds support for <strong>closed class hierarchies<\/strong>. Adding the <code>closed<\/code> 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<\/code> for exhaustiveness:<\/p>\n<pre><code class=\"language-csharp\">public closed record class PaymentEvent(string PaymentId);\n\npublic sealed record class PaymentInitiated(string PaymentId) : PaymentEvent(PaymentId);\npublic sealed record class PaymentAuthorized(string PaymentId, decimal Amount) : PaymentEvent(PaymentId);\npublic sealed record class PaymentFailed(string PaymentId, string Reason) : PaymentEvent(PaymentId);<\/code><\/pre>\n<p>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<\/code> enforces the fixed set in the language and lets STJ infer the derived types instead of registering each one explicitly.<\/p>\n<p>The <code>closed<\/code> modifier affects the C# type relationship; it doesn\u2019t change JSON by itself. The <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/unions-and-closed-hierarchies-in-aspnetcore\/#serializing-and-deserializing-closed-hierarchies\">serialization section below<\/a> shows the default behavior first, followed by the opt-in polymorphic behavior.<\/p>\n<h2>Choosing a model for your API<\/h2>\n<p>A union isn\u2019t always the best choice whenever an API has several possible shapes. Start with whether you control the case types and the JSON contract.<\/p>\n<p>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>, <code>PaymentAuthorized<\/code>, and <code>PaymentFailed<\/code> can derive from <code>PaymentEvent<\/code>. The discriminator makes the JSON self-describing, while <code>closed<\/code> lets the compiler check that every known event is handled.<\/p>\n<p>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\u2019t consider an open hierarchy exhaustive.<\/p>\n<p>Choose a union when you must preserve an established discriminator-free contract, or when the cases can\u2019t derive from one base class. This includes primitives and existing types you don\u2019t control. A union preserves each case\u2019s existing JSON shape and still gives callers a fixed set of types to handle.<\/p>\n<p>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.<\/p>\n<p>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.<\/p>\n<p>Closed-hierarchy serialization uses the same STJ polymorphism infrastructure described above. The <code>closed<\/code> modifier adds language-level guardrails, and <code>InferClosedTypePolymorphism<\/code> lets STJ discover the derived types from those guardrails.<\/p>\n<p>ASP.NET Core union support comes from STJ. Unions therefore work where ASP.NET Core uses STJ: JSON request and response bodies, <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/unions-and-closed-hierarchies-in-aspnetcore\/#signalr\">SignalR<\/a>\u2018s <code>JsonHubProtocol<\/code>, and <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/unions-and-closed-hierarchies-in-aspnetcore\/#blazor\">Blazor<\/a>\u2018s JavaScript interop, persisted component state, and prerendered component parameters. They aren\u2019t supported for query strings, route values, headers, or form fields. For more information, see <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/unions-and-closed-hierarchies-in-aspnetcore\/#limitations\">Limitations<\/a>.<\/p>\n<h2>Serializing and deserializing unions<\/h2>\n<p>A union type is declared with the <code>union<\/code> keyword and a list of case types. The examples throughout this article use the following declarations:<\/p>\n<pre><code class=\"language-csharp\">public union UnionIntString(int, string);\npublic union UnionBoolString(bool, string);\npublic union UnionNullableIntString(int?, string);\n\npublic record Cat(string Name, string Coat);\npublic record Dog(string Name, string Breed);\npublic union UnionPet(Cat, Dog);<\/code><\/pre>\n<p>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\u2019s own JSON contract. There\u2019s no envelope object, no <code>$type<\/code> field, and no discriminator of any kind:<\/p>\n<pre><code class=\"language-csharp\">JsonSerializer.Serialize(new UnionIntString(42));      \/\/ 42\nJsonSerializer.Serialize(new UnionIntString(\"hello\")); \/\/ \"hello\"\nJsonSerializer.Serialize(new UnionPet(new Cat(\"Whiskers\", \"Tabby\"))); \/\/ { \"name\": \"Whiskers\", \"coat\": \"Tabby\" }<\/code><\/pre>\n<p>STJ can select a union case automatically when the cases use different JSON types. For example, <code>UnionBoolString(bool, string)<\/code> is unambiguous because a JSON boolean maps to <code>bool<\/code> and a JSON string maps to <code>string<\/code>.<\/p>\n<p>When multiple cases could match the same JSON value, STJ needs help choosing one. <code>UnionPet(Cat, Dog)<\/code> is ambiguous because both cases are JSON objects. If the payload follows an established discriminator-free contract that can\u2019t be changed, the built-in structural classifier can distinguish object cases by their property names:<\/p>\n<pre><code class=\"language-csharp\">[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]\npublic union UnionPet(Cat, Dog);<\/code><\/pre>\n\n<div class=\"alert alert-danger\">\n<p class=\"alert-divider\"><i class=\"fabric-icon fabric-icon--ErrorBadge\"><\/i><strong>Structural classification tradeoffs<\/strong><\/p>\n<p>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.<\/p><\/div>\n<p>If the cases can\u2019t be distinguished by structure, an advanced scenario can supply a custom <code>JsonTypeClassifier<\/code>.<\/p>\n<p>The opening <code>IntOrString<\/code> 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.<\/p>\n<p>For an overview of STJ\u2019s union support and classifier APIs, see <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/whats-new\/dotnet-11\/libraries#c-union-type-serialization\">What\u2019s new in .NET libraries for .NET 11<\/a>.<\/p>\n<h2>Serializing and deserializing closed hierarchies<\/h2>\n<p>The <code>closed<\/code> modifier doesn\u2019t require polymorphic serialization. When code uses a concrete derived type, STJ serializes and deserializes it like any other type and doesn\u2019t add a discriminator:<\/p>\n<pre><code class=\"language-csharp\">var json = JsonSerializer.Serialize(new PaymentAuthorized(\"p-123\", 42.5m), JsonSerializerOptions.Web);\n\nvar payment = JsonSerializer.Deserialize&lt;PaymentAuthorized&gt;(json, JsonSerializerOptions.Web);<\/code><\/pre>\n<pre><code class=\"language-json\">{\"paymentId\":\"p-123\",\"amount\":42.5}<\/code><\/pre>\n<p>This round trip works because both the writer and reader use the concrete <code>PaymentAuthorized<\/code> type. If an API instead uses the <code>PaymentEvent<\/code> base type, the JSON needs to identify which derived type to create. Polymorphic serialization can be enabled on the closed base type:<\/p>\n<pre><code class=\"language-csharp\">[JsonPolymorphic(InferClosedTypePolymorphism = true)]\npublic closed record class PaymentEvent(string PaymentId);<\/code><\/pre>\n<p>STJ then infers the derived types in the closed hierarchy and uses their type names as discriminators. An endpoint can deserialize a <code>PaymentEvent<\/code> request and serialize the active derived type back:<\/p>\n<pre><code class=\"language-csharp\">app.MapPost(\"\/payment-event\", (PaymentEvent paymentEvent) =&gt; paymentEvent);<\/code><\/pre>\n<p>This JSON is deserialized as <code>PaymentAuthorized<\/code> and serialized with the same discriminator:<\/p>\n<pre><code class=\"language-json\">{\"$type\":\"PaymentAuthorized\",\"paymentId\":\"p-123\",\"amount\":42.5}<\/code><\/pre>\n<p>The opt-in can instead be applied to a JSON pipeline with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/whats-new\/dotnet-11\/libraries#closed-hierarchy-polymorphism-inference\"><code>JsonSerializerOptions.InferClosedTypePolymorphism<\/code><\/a>. 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.<\/p>\n<h2>Minimal APIs<\/h2>\n<p>Unions work as request body parameters and as return types in both the runtime path (<code>RequestDelegateFactory<\/code>) and the source-generated <a href=\"https:\/\/learn.microsoft.com\/aspnet\/core\/fundamentals\/aot\/request-delegate-generator\/rdg\">Request Delegate Generator (RDG)<\/a>. Behavior is identical across both.<\/p>\n<pre><code class=\"language-csharp\">var builder = WebApplication.CreateBuilder(args);\nvar app = builder.Build();\n\n\/\/ Request body: UnionBoolString is unambiguous (bool vs string), so it binds without a classifier.\napp.MapPost(\"\/flag\", (UnionBoolString flag) =&gt; flag);\n\n\/\/ Request body: UnionPet's cases are both objects, so it uses the built-in classifier shown earlier.\napp.MapPost(\"\/pet\", ([FromBody] UnionPet pet) =&gt; TypedResults.Ok(pet));\n\n\/\/ Return types: only the active case is serialized, and no classifier is needed to write.\napp.MapGet(\"\/value\", () =&gt; new UnionIntString(42));\napp.MapGet(\"\/pet\", () =&gt; new UnionPet(new Cat(\"Whiskers\", \"Tabby\")));\n\napp.Run();<\/code><\/pre>\n<p>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 <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.aspnetcore.http.typedresults\"><code>TypedResults<\/code><\/a>:<\/p>\n<pre><code class=\"language-csharp\">app.MapGet(\"\/maybe\", () =&gt; new UnionNullableIntString((int?)null));\napp.MapGet(\"\/typed\", () =&gt; TypedResults.Ok(new UnionPet(new Cat(\"Whiskers\", \"Tabby\"))));<\/code><\/pre>\n<p>A union can also be a property of another model, an item streamed from an <code>IAsyncEnumerable&lt;T&gt;<\/code>, or the body slot of an <code>[AsParameters]<\/code> container. Union serialization also respects options configured through <code>ConfigureHttpJsonOptions<\/code>.<\/p>\n<h2>MVC controllers<\/h2>\n<p>Unions flow through the STJ input and output formatters, so controllers support them as action parameters and return types, including <code>Task&lt;TUnion&gt;<\/code> and <code>ValueTask&lt;TUnion&gt;<\/code> results:<\/p>\n<pre><code class=\"language-csharp\">[ApiController]\n[Route(\"[controller]\/[action]\")]\n[Produces(\"application\/json\")]\npublic class PetsController : ControllerBase\n{\n    [HttpPost]\n    public UnionBoolString Echo([FromBody] UnionBoolString value) =&gt; value;\n\n    [HttpGet(\"{kind}\")]\n    public UnionIntString Primitive(string kind) =&gt; kind switch\n    {\n        \"value\" =&gt; new UnionIntString(42),\n        _ =&gt; new UnionIntString(\"hi\"),\n    };\n}<\/code><\/pre>\n<p>Union serialization and deserialization follow the <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/unions-and-closed-hierarchies-in-aspnetcore\/#serializing-and-deserializing-unions\">rules described earlier<\/a>. Controllers use <code>JsonSerializerDefaults.Web<\/code> just like Minimal APIs, so the <code>IntOrString<\/code> input caveat in that section applies to controller actions too.<\/p>\n<h2>SignalR<\/h2>\n<p><code>JsonHubProtocol<\/code> forwards reads and writes to <code>System.Text.Json<\/code>, so unions work as hub method parameters, return values, and stream items without any extra configuration:<\/p>\n<pre><code class=\"language-csharp\">public class ChatHub : Hub\n{\n    \/\/ Union argument (client \u2192 server).\n    public Task Send(UnionIntString message) =&gt; Clients.All.SendAsync(\"Receive\", message);\n\n    \/\/ Union return value (server \u2192 client).\n    public UnionPet GetPet() =&gt; new UnionPet(new Cat(\"Whiskers\", \"Tabby\"));\n\n    \/\/ Union stream items (server \u2192 client).\n    public async IAsyncEnumerable&lt;UnionIntString&gt; Stream()\n    {\n        yield return 1;\n        yield return \"two\";\n    }\n}<\/code><\/pre>\n<p>On the read path, the parameter, return, or stream-item <code>Type<\/code> resolved from the invocation binder drives the union converter, including any <code>[JsonUnion]<\/code> classifier.<\/p>\n<p>Unlike HTTP JSON binding in Minimal APIs and MVC, <code>JsonHubProtocol<\/code> doesn\u2019t treat a JSON <code>String<\/code> token as ambiguous for numeric cases, so a union such as <code>UnionIntString(int, string)<\/code> round-trips both the <code>int<\/code> and <code>string<\/code> cases without a classifier. Unions whose cases share the <code>StartObject<\/code> token, such as <code>UnionPet(Cat, Dog)<\/code>, are still ambiguous on read and require a classifier.<\/p>\n<p>Unions are supported only with <code>JsonHubProtocol<\/code>. The MessagePack and Newtonsoft.Json hub protocols don\u2019t support unions, because their underlying serializers have no union support.<\/p>\n<h2>Blazor<\/h2>\n<p>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<\/code> and follow the same rules described earlier in this article.<\/p>\n<h3>Component parameters<\/h3>\n<p>A component parameter is set by direct assignment when a component is rendered from Razor markup or through <code>RenderTreeBuilder.AddComponentParameter<\/code>. In-process rendering doesn\u2019t serialize parameters, so a union parameter works with no extra configuration:<\/p>\n<pre><code class=\"language-razor\">&lt;PetCard Pet=\"@(new UnionPet(new Cat(\"Whiskers\", \"Tabby\")))\" \/&gt;<\/code><\/pre>\n<pre><code class=\"language-csharp\">public class PetCard : ComponentBase\n{\n    [Parameter]\n    public UnionPet Pet { get; set; }\n}<\/code><\/pre>\n<h3>JavaScript interop<\/h3>\n<p>JavaScript interop through <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.jsinterop.ijsruntime\"><code>IJSRuntime<\/code><\/a> serializes arguments and return values with <code>System.Text.Json<\/code>. This is useful when a JavaScript API already accepts a union-shaped contract. For example, <a href=\"https:\/\/developer.mozilla.org\/docs\/Web\/API\/Element\/scrollIntoView\"><code>Element.scrollIntoView<\/code><\/a> accepts either a Boolean alignment shorthand or an options object:<\/p>\n<pre><code class=\"language-csharp\">public sealed record ScrollIntoViewOptions(string Behavior, string Block, string Inline);\n\npublic union ScrollIntoViewArgument(bool, ScrollIntoViewOptions);\n\nprivate ValueTask ScrollAsync(\n    ElementReference element,\n    ScrollIntoViewArgument argument) =&gt;\n    JS.InvokeVoidAsync(\"scrollElementIntoView\", element, argument);\n\nawait ScrollAsync(target, false);\nawait ScrollAsync(target, new ScrollIntoViewOptions(\"instant\", \"start\", \"nearest\"));<\/code><\/pre>\n<pre><code class=\"language-js\">window.scrollElementIntoView = (element, argument) =&gt;\n    element.scrollIntoView(argument);<\/code><\/pre>\n<p>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.<\/p>\n<h3>Persisted component state<\/h3>\n<p><a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.aspnetcore.components.persistentcomponentstate\"><code>PersistentComponentState<\/code><\/a> serializes state with <code>System.Text.Json<\/code>, so a union round-trips through <code>PersistAsJson<\/code> and <code>TryTakeFromJson<\/code>, including a union whose active case is <code>null<\/code>.<\/p>\n<h3>Prerendering<\/h3>\n<p>When a component is prerendered with Blazor Server or Blazor WebAssembly, its parameters are serialized into the component marker with <code>System.Text.Json<\/code> 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<\/code>, such as a <code>UnionNullableIntString<\/code> that holds a null <code>int?<\/code>.<\/p>\n<h2>OpenAPI<\/h2>\n<p>The OpenAPI document represents a union as an <a href=\"https:\/\/spec.openapis.org\/oas\/latest#composition-and-inheritance-polymorphism\"><code>anyOf<\/code><\/a> schema, with one entry per case type:<\/p>\n<pre><code class=\"language-json\">\"Cat\": {\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": { \"type\": \"string\" },\n    \"coat\": { \"type\": \"string\" }\n  }\n},\n\"Dog\": {\n  \"type\": \"object\",\n  \"properties\": {\n    \"name\": { \"type\": \"string\" },\n    \"breed\": { \"type\": \"string\" }\n  }\n},\n\"UnionIntString\": {\n  \"anyOf\": [\n    { \"type\": \"integer\", \"format\": \"int32\" },\n    { \"type\": \"string\" }\n  ]\n},\n\"UnionPet\": {\n  \"type\": \"object\",\n  \"anyOf\": [\n    { \"$ref\": \"#\/components\/schemas\/Cat\" },\n    { \"$ref\": \"#\/components\/schemas\/Dog\" }\n  ]\n}<\/code><\/pre>\n<p>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<\/code> and <code>Dog<\/code> schemas referenced by <code>UnionPet<\/code> are the same components that a standalone <code>Cat<\/code> or <code>Dog<\/code> endpoint produces. This differs from polymorphic types, whose derived schemas are lifted to prefixed component names because they carry a <code>$type<\/code> discriminator.<\/p>\n<p>An endpoint can also produce multiple response types for the same status code and content type. The <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.aspnetcore.mvc.apiexplorer\"><code>Microsoft.AspNetCore.Mvc.ApiExplorer<\/code><\/a> namespace preserves every declared response type, and the generated document emits an <code>anyOf<\/code> schema when several types share a content type:<\/p>\n<pre><code class=\"language-csharp\">public record Tyrannosaurus(string Name, double BiteForceNewtons);\npublic record Triceratops(string Name, int HornCount);\npublic record Velociraptor(string Name, double TopSpeedKmh);\npublic union UnionDinosaur(Tyrannosaurus, Triceratops, Velociraptor);\n\napp.MapGet(\"\/any-of\", () =&gt; Results.Ok())\n    .Produces&lt;UnionPet&gt;(StatusCodes.Status200OK, \"application\/json\")\n    .Produces&lt;UnionDinosaur&gt;(StatusCodes.Status200OK, \"application\/json\");<\/code><\/pre>\n<p>The same support applies to MVC controllers that declare multiple <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/microsoft.aspnetcore.mvc.producesresponsetypeattribute\"><code>ProducesResponseTypeAttribute<\/code><\/a> attributes for one status code and content type.<\/p>\n<h2>Limitations<\/h2>\n<p>Union support requires <code>System.Text.Json<\/code>. Binding sources that <em>don\u2019t<\/em> route through STJ don\u2019t support unions:<\/p>\n<ul>\n<li>Query string values<\/li>\n<li>Route values<\/li>\n<li>Header values<\/li>\n<li>Form fields<\/li>\n<\/ul>\n<p>These sources bind a string token to a target type without JSON parsing, so there\u2019s no reliable way to choose a union case. A single query value such as <code>?id=42<\/code> provides no way to know whether to bind <code>int<\/code>, <code>string<\/code>, <code>Guid<\/code>, or another case. Because of this ambiguity, unions are intentionally not supported in these binding sources.<\/p>\n<p>In Blazor, the same limitation applies to component parameters supplied from non-body sources, including <code>[SupplyParameterFromQuery]<\/code> and form binding with <code>[SupplyParameterFromForm]<\/code>. These bind string or form values without JSON parsing, so they don\u2019t support unions.<\/p>\n<p>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 <a href=\"https:\/\/github.com\/dotnet\/aspnetcore\/issues\/66648\">union parameter binding issue<\/a>.<\/p>\n<h2>Summary<\/h2>\n<p>The two opening examples make the choice concrete. Kubernetes <code>maxUnavailable<\/code> fits a union because it is an existing discriminator-free contract that accepts either an <code>int<\/code> or a <code>string<\/code>. <code>PaymentEvent<\/code> fits a closed hierarchy because its cases form one related family and the JSON can identify each event with a discriminator.<\/p>\n<p>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.<\/p>\n<h2>Additional resources<\/h2>\n<ul>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/csharp\/language-reference\/builtin-types\/union\">C# union types (language reference)<\/a><\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/aspnet\/core\/release-notes\/aspnetcore-11\">What\u2019s new in ASP.NET Core in .NET 11<\/a><\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/standard\/serialization\/system-text-json\/overview\">JSON serialization and deserialization in .NET<\/a><\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/dotnet\/standard\/serialization\/system-text-json\/polymorphism\">How to serialize polymorphic types with System.Text.Json<\/a><\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/aspnet\/core\/fundamentals\/minimal-apis\/parameter-binding\">Parameter binding in Minimal API applications<\/a><\/li>\n<li><a href=\"https:\/\/learn.microsoft.com\/aspnet\/core\/fundamentals\/minimal-apis\/responses\">How to create responses in Minimal API apps<\/a><\/li>\n<\/ul>\n<p>The post <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/unions-and-closed-hierarchies-in-aspnetcore\/\">Use C# unions and closed hierarchies in ASP.NET Core<\/a> appeared first on <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\">.NET Blog<\/a>.<\/p>","protected":false},"excerpt":{"rendered":"<p>Sometimes an API contract says that a value can have more than one JSON type. Kubernetes has a practical example: [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":94,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[7],"tags":[],"class_list":["post-5049","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-dotnet"],"_links":{"self":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/5049","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/comments?post=5049"}],"version-history":[{"count":0,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/5049\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media\/94"}],"wp:attachment":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media?parent=5049"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/categories?post=5049"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/tags?post=5049"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}