Huny's Dev Blog
huny.dev

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.

Hun Jang
Hun Jang Nov 14, 2026

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 with public 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 that switch expressions cover every declared case (CS8509).
  • Closed Hierarchies (closed): Applying the contextual closed modifier to a base class restricts direct inheritance to the declaring assembly. Because the compiler knows the complete set of direct descendants at compile time, a switch expression 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:

  • closed is non-transitive and does not emit a JSON $type discriminator on its own: A closed class is implicitly abstract and cannot be combined with sealed, static, or an explicit abstract modifier. Crucially, derivation restriction is not transitive: a non-closed, non-sealed descendant of a closed class can still be derived from in external assemblies (What's new in C# 15 — Closed hierarchies). Moreover, closed only governs C# inheritance—to serialize a closed base type polymorphically with a $type discriminator in System.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.Json unpacks the union wrapper and writes only the active case value using that case's own contract—with no outer wrapper object and no $type discriminator. Serializing new UnionIntString(42) produces 42, and serializing new UnionPet(new Cat("Whiskers", "Tabby")) produces {"name":"Whiskers","coat":"Tabby"} (Use C# unions and closed hierarchies in ASP.NET Core).
  • JsonSerializerOptions.Web breaks default union(int, string) deserialization on JSON strings: Under default JsonSerializerOptions, a JSON number maps to int and a JSON string maps to string. Under JsonSerializerOptions.Web (used by ASP.NET Core Minimal APIs and MVC), int can also be read from a JSON string, so both int and string claim the JSON String token type. Consequently, deserializing even "25%" throws JsonException for ambiguity before parsing either case unless a custom classifier is configured (Serialize union types with System.Text.Json). By contrast, SignalR's JsonHubProtocol does not treat JSON String tokens as ambiguous for numeric cases, allowing UnionIntString(int, string) to round-trip without a classifier.
  • JsonUnionTypeStructuralClassifier only 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, required properties, and JsonUnmappedMemberHandling.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 as Guid and string) throw NotSupportedException when building the classifier (Distinguish cases with the same JSON token type).
  • ASP.NET Core supports unions only on System.Text.Json boundaries: Because raw string tokens like ?id=42 are 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 SignalR MessagePack or Newtonsoft.Json hub 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 바인딩 경계 점검하기C# 15 유니언 타입(union)과 닫힌 계층(closed) 해부: System.Text.Json 분류기·Web 옵션 모호성 함정과 ASP.NET Core 바인딩 경계 점검하기

추천 글