![]() |
|
[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: maxUnavailableCode: 2Code: "25%"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: 2Code: "25%"Code: IntOrStringCode: unionCode: intCode: stringCode: boolCode: DateTimeEach 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: switchCode: [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: _ => ...Code: switchCode: IntOrStringFor example, suppose a Code: stringCode: boolCode: decimalCode: [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: objectBefore native unions, developers usually reached for Code: objectCode: objectCode: intCode: stringCode: System.Text.JsonOther 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: CircleCode: SquareCode: ShapeSTJ has been able to serialize such a hierarchy with a discriminator. Mark the base type with Code: [JsonPolymorphic]Code: [JsonDerivedType]Code: $typeCode: [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: switchClosed hierarchies C# 15 adds support for closed class hierarchies. Adding the Code: closedCode: switchCode: [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: closedThe Code: closedChoosing 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: PaymentInitiatedCode: PaymentAuthorizedCode: PaymentFailedCode: PaymentEventCode: closedKeep 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: closedCode: InferClosedTypePolymorphismASP.NET Core union support comes from STJ. Unions therefore work where ASP.NET Core uses STJ: JSON request and response bodies, SignalR‘s Code: JsonHubProtocolSerializing and deserializing unions A union type is declared with the Code: unionCode: [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: $typeCode: [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)Code: boolCode: stringWhen multiple cases could match the same JSON value, STJ needs help choosing one. Code: UnionPet(Cat, Dog)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: JsonTypeClassifierThe opening Code: IntOrStringFor 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: closedCode: [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: PaymentAuthorizedCode: PaymentEventCode: [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: PaymentEventCode: [code]app.MapPost("/payment-event", (PaymentEvent paymentEvent) => paymentEvent);[/code]This JSON is deserialized as Code: PaymentAuthorizedCode: [code]{"$type":"PaymentAuthorized","paymentId":"p-123","amount":42.5}[/code]The opt-in can instead be applied to a JSON pipeline with Code: JsonSerializerOptions.InferClosedTypePolymorphismMinimal APIs Unions work as request body parameters and as return types in both the runtime path ( Code: RequestDelegateFactoryCode: [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: TypedResultsCode: [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>Code: [AsParameters]Code: ConfigureHttpJsonOptionsMVC controllers Unions flow through the STJ input and output formatters, so controllers support them as action parameters and return types, including Code: Task<TUnion>Code: ValueTask<TUnion>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.WebCode: IntOrStringSignalR Code: JsonHubProtocolCode: System.Text.JsonCode: [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: TypeCode: [JsonUnion]Unlike HTTP JSON binding in Minimal APIs and MVC, Code: JsonHubProtocolCode: StringCode: UnionIntString(int, string)Code: intCode: stringCode: StartObjectCode: UnionPet(Cat, Dog)Unions are supported only with Code: JsonHubProtocolBlazor 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.JsonComponent parameters A component parameter is set by direct assignment when a component is rendered from Razor markup or through Code: RenderTreeBuilder.AddComponentParameterCode: [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: IJSRuntimeCode: System.Text.JsonCode: Element.scrollIntoViewCode: [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: PersistentComponentStateCode: System.Text.JsonCode: PersistAsJsonCode: TryTakeFromJsonCode: nullPrerendering When a component is prerendered with Blazor Server or Blazor WebAssembly, its parameters are serialized into the component marker with Code: System.Text.JsonCode: nullCode: UnionNullableIntStringCode: int?OpenAPI The OpenAPI document represents a union as an Code: anyOfCode: [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: CatCode: DogCode: UnionPetCode: CatCode: DogCode: $typeAn endpoint can also produce multiple response types for the same status code and content type. The Code: Microsoft.AspNetCore.Mvc.ApiExplorerCode: anyOfCode: [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: ProducesResponseTypeAttributeLimitations Union support requires Code: System.Text.Json
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=42Code: intCode: stringCode: GuidIn Blazor, the same limitation applies to component parameters supplied from non-body sources, including Code: [SupplyParameterFromQuery]Code: [SupplyParameterFromForm]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: maxUnavailableCode: intCode: stringCode: PaymentEventBoth 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. |