C# 15 Union Types vs. Closed Hierarchies in .NET 11: System.Text.Json Classifiers, Web Option Ambiguity Traps, and ASP.NET Core Binding Boundaries
Compare C# 15 union types (union) and closed hierarchies (closed) in .NET 11 across compiler exhaustiveness checking, System.Text.Json serialization, JsonSerializerOptions.Web ambiguity traps, JsonUnionTypeStructuralClassifier boundaries, and ASP.NET Core binding rules.
C# 15 Union Types (union) vs. Closed Hierarchies (closed) & System.Text.Json / ASP.NET Core Architecture Diagram (Conceptual SVG Illustration)
With the general availability of .NET 11 and C# 15 during .NET Conf 2026 (November 10–13, 2026), projects targeting net11.0 gain union types (union) and closed hierarchies (closed) as default language features without requiring <LangVersion>preview</LangVersion> (What's new in C# 15). While both features allow the compiler to enforce exhaustive switch expressions (CS8509) without a catch-all _ arm, they differ fundamentally in type relationships, inheritance transitivity, and System.Text.Json (STJ) / ASP.NET Core serialization semantics (Serialize union types with System.Text.Json, Use C# unions and closed hierarchies in ASP.NET Core).
The core architectural rule is clear: choose union when grouping unrelated or primitive types (int, string, external records) or preserving an established discriminator-free JSON contract, and choose a closed class hierarchy paired with [JsonPolymorphic(InferClosedTypePolymorphism = true)] when you own the domain types and want stable $type discriminator serialization. In particular, ASP.NET Core's JsonSerializerOptions.Web enables reading numbers from JSON strings, which causes union(int, string) request bodies to throw JsonException on any JSON string (even "25%") unless you configure a custom JsonTypeClassifier, and non-JSON binding sources (query strings, routes, headers, forms) do not support unions at all. This article synthesizes the official .NET 11 release announcement, What's new in C# 15, System.Text.Json union serialization guide, and ASP.NET Core unions and closed hierarchies guide, verified via a local Python classification and exhaustiveness simulation script rather than live server throughput benchmarks.
1. Syntax and Compiler Exhaustiveness (CS8509): union vs. closed
Prior to C# 15, returning a fixed set of outcomes from a method required either returning object, forcing unrelated types under a shared base class, or writing boilerplate wrapper types. C# 15 addresses both scenarios directly (What's new in C# 15):
- Union Types (
union): Declared withpublic union Name(Case1, Case2, ...);, a union holds one value from a fixed list of case types—including primitives, structs, records, classes, interfaces, or nullable types. Each case converts implicitly to the union type, and the compiler verifies thatswitchexpressions cover every declared case (CS8509).
- Closed Hierarchies (
closed): Applying the contextualclosedmodifier to a base class restricts direct inheritance to the declaring assembly. Because the compiler knows the complete set of direct descendants at compile time, aswitchexpression that handles every direct descendant is exhaustive without a default arm.
2. Five Technical Boundaries to Separate in Production
When introducing C# 15 unions and closed hierarchies into backend APIs, keep these five boundaries distinct:
closedis non-transitive and does not emit a JSON$typediscriminator on its own: Aclosedclass is implicitlyabstractand cannot be combined withsealed,static, or an explicitabstractmodifier. Crucially, derivation restriction is not transitive: a non-closed, non-sealed descendant of aclosedclass can still be derived from in external assemblies (What's new in C# 15 — Closed hierarchies). Moreover,closedonly governs C# inheritance—to serialize a closed base type polymorphically with a$typediscriminator inSystem.Text.Json, you must opt in via[JsonPolymorphic(InferClosedTypePolymorphism = true)](Serialize union types with System.Text.Json).
- Union serialization is completely envelope-free:
System.Text.Jsonunpacks the union wrapper and writes only the active case value using that case's own contract—with no outer wrapper object and no$typediscriminator. Serializingnew UnionIntString(42)produces42, and serializingnew UnionPet(new Cat("Whiskers", "Tabby"))produces{"name":"Whiskers","coat":"Tabby"}(Use C# unions and closed hierarchies in ASP.NET Core).
JsonSerializerOptions.Webbreaks defaultunion(int, string)deserialization on JSON strings: Under defaultJsonSerializerOptions, a JSON number maps tointand a JSON string maps tostring. UnderJsonSerializerOptions.Web(used by ASP.NET Core Minimal APIs and MVC),intcan also be read from a JSON string, so bothintandstringclaim the JSONStringtoken type. Consequently, deserializing even"25%"throwsJsonExceptionfor ambiguity before parsing either case unless a custom classifier is configured (Serialize union types with System.Text.Json). By contrast, SignalR'sJsonHubProtocoldoes not treat JSONStringtokens as ambiguous for numeric cases, allowingUnionIntString(int, string)to round-trip without a classifier.
JsonUnionTypeStructuralClassifieronly inspects root-level property names: When two union cases both serialize as JSON objects (union Pet(Dog, Cat)), default token classification cannot tell them apart. Applying[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]narrows candidates using root-level property names,requiredproperties, andJsonUnmappedMemberHandling.Disallow, succeeding only when exactly one candidate case remains. It never inspects property values, nested objects, or array elements, and unions combining multiple non-object cases with the same JSON token (such asGuidandstring) throwNotSupportedExceptionwhen building the classifier (Distinguish cases with the same JSON token type).
- ASP.NET Core supports unions only on
System.Text.Jsonboundaries: Because raw string tokens like?id=42are bound without JSON parsing, ASP.NET Core intentionally does not support unions in query strings, route values, headers, or form fields (including Blazor[SupplyParameterFromQuery]and[SupplyParameterFromForm]), nor in SignalRMessagePackorNewtonsoft.Jsonhub protocols (ASP.NET Core Limitations).
| Dimension | C# 15 Union Type ( union) | C# 15 Closed Hierarchy ( closed) | Open Polymorphic Hierarchy ( abstract) |
| Declaration | public union Payload(int, string, Message); | public closed record Event; | public abstract record Shape; |
| Case Relationship | Unrelated types allowed (primitives, structs, records, interfaces) | Must inherit directly from the closed base in the same assembly | Open inheritance across any assembly |
| Exhaustive switch | Compiler-enforced ( CS8509, no _ arm required) | Compiler-enforced for direct children (mark intermediate nodes closed) | Not exhaustive (requires _ fallback arm) |
| STJ JSON Output | Unwrapped active case only (no envelope or $type) | Standard object by default; $type discriminator with InferClosedTypePolymorphism = true | $type discriminator via explicit [JsonDerivedType] registrations |
| OpenAPI 3.2 Schema | anyOf reusing standalone component $ref schemas | Polymorphic schema with $type discriminator | Polymorphic schema for explicitly registered derived types |
| Best Fit | Kubernetes IntOrString, external discriminator-free contracts, mixed primitives | First-party domain events and API models where you control the JSON contract | Plugin architectures extensible by external assemblies |
3. System.Text.Json & ASP.NET Core Union Behavior Matrix
The table below summarizes how union cases behave across System.Text.Json configurations and ASP.NET Core bindings (Serialize union types with System.Text.Json, Use C# unions and closed hierarchies in ASP.NET Core).
| Union Shape & Binding Path | Default Deserialization Behavior | Exception / Failure Mode | Recommended Resolution |
union(bool, string)(Distinct JSON token types) | JSON boolean selects bool; JSON string selects string | None | Works out of the box across Minimal APIs, MVC, SignalR, and Blazor |
union(int, string) +Default STJ / SignalR JsonHubProtocol | JSON number selects int; JSON string selects string | None | JsonHubProtocol does not treat string tokens as numeric candidates |
union(int, string) +JsonSerializerOptions.Web (Minimal API / MVC Input) | Fails on any JSON string payload (including "25%") | Throws JsonException (both int and string claim the JSON String token) | Use as output-only or register a custom JsonTypeClassifierFactory for request bodies |
union Pet(Dog, Cat)(Multiple JSON object cases) | Token classifier cannot distinguish two StartObject tokens | Throws JsonException / emits source-generator diagnostic | Annotate with [JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))] or use a closed hierarchy |
union(Guid, string) or Nested Unions | Multiple non-object cases sharing String token / nested union | Throws NotSupportedException when building classifier | Wrap primitives in distinct records or switch to a discriminator-backed closed hierarchy |
| Query, Route, Header, Form ( ?id=42, [FromQuery], [SupplyParameterFromForm]) | Bypasses System.Text.Json parser | Intentionally unsupported (raw string token is ambiguous) | Bind unions only from JSON bodies ( [FromBody]), JS interop, or PersistentComponentState |
4. Practical C# 15 Code and Local Python Verification
4.1 Structural Classification, Inferred Closed Polymorphism, and C# 15 Everyday Features
The following C# 15 (net11.0) example demonstrates JsonUnionTypeStructuralClassifier for discriminator-free object unions, [JsonPolymorphic(InferClosedTypePolymorphism = true)] for a closed event hierarchy, and C# 15's new collection expression arguments (with(...)) and extension indexers (What's new in C# 15, Serialize union types with System.Text.Json).
4.2 Local Python Verification Script
The Python script below models and verifies five core rules from the .NET 11 specification: default vs. JsonSerializerOptions.Web token classification on union(int, string), Guid+string collision, root-property structural narrowing in JsonUnionTypeStructuralClassifier, null selection of the first nullable union case, and non-transitivity of closed hierarchies when intermediate descendants are left unsealed.
5. Architecture & Migration Checklist
Choose between union and closed based on contract ownership: Use union when preserving an external discriminator-free contract or combining primitives; use a closed hierarchy with [JsonPolymorphic(InferClosedTypePolymorphism = true)] when you own the domain types and want resilient $type discrimination that survives property renames.
Audit union(int, string) request bodies under JsonSerializerOptions.Web: Because ASP.NET Core Minimal APIs and MVC allow reading numbers from JSON strings by default, binding a numeric+string union from a request body throws JsonException on string inputs unless you supply a custom JsonTypeClassifierFactory.
Mark every descendant of a closed class as sealed or closed: Because closed is not transitive, leaving an intermediate derived class open allows external assemblies to subclass it and breaks exhaustiveness guarantees down the subtree.
Keep unions out of query, route, header, and form parameters: Non-JSON binding sources ([FromQuery], [FromRoute], [FromHeader], [FromForm], [SupplyParameterFromQuery]) do not parse JSON tokens and do not support C# 15 unions.
Promote CS8509 to a build error (<WarningsAsErrors>CS8509</WarningsAsErrors>): Ensure that adding a new case to a union or closed hierarchy fails compilation immediately wherever a switch expression is missing the new branch.
6. Official References (Verified 2026-11-14)
Korean version:
C# 15 유니언 타입(union)과 닫힌 계층(closed) 해부: System.Text.Json 분류기·Web 옵션 모호성 함정과 ASP.NET Core 바인딩 경계 점검하기