Repository navigation
Description
Background and motivation
C# union types (tracked in #125449) ship in System.Text.Json with a default classifier that discriminates among union cases by JSON token kind ({, [, string, number, bool, null). Two cases that share a token kind, such as Dog and Cat objects, cannot be distinguished by the default.
Today the only recourse is to write a custom JsonTypeClassifierFactory for each such union. Property-name matching, ambiguity handling, required properties, and unmapped-member behavior are non-trivial to implement consistently.
This proposal adds one classifier for structural object discrimination. It doesn't inspect primitive values, array elements, property values, or nested JSON.
Contributes to #125449.
API proposal
namespace System.Text.Json.Serialization;
public class JsonUnionTypeStructuralClassifier : JsonTypeClassifierFactory
{
public JsonUnionTypeStructuralClassifier();
}Prototype: eiriktsarpalis@ed0d3be.
For non-object payloads, classification uses only the JSON token category. At most one union case may claim each array, string, number, or boolean category. Multiple cases claiming the same category are rejected with NotSupportedException when the classifier is built. String contents and array elements are never inspected.
JSON objects are classified using root-level property names. The case declaring the greatest number of property names present in the payload wins. Property values are skipped without inspection, required properties can disqualify a case, and JsonUnmappedMemberHandling.Disallow can disqualify cases with undeclared properties. A scoring tie remains ambiguous and results in JsonException.
Object cases with identical property-name sets are rejected when the classifier is built. Nested union cases are unsupported because classifying them would require recursive inspection. CanClassify returns false for non-union contexts.
API usage
Object cases are matched by property name:
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Pet(Dog, Cat);
public sealed class Dog { public string? Name { get; set; } public string? Breed { get; set; } }
public sealed class Cat { public string? Name { get; set; } public int Lives { get; set; } }
JsonSerializer.Deserialize<Pet>("""{"Name":"Rex","Breed":"Husky"}"""); // Dog
JsonSerializer.Deserialize<Pet>("""{"Name":"Tiger","Lives":9}"""); // Cat
// Both cases match only "Name", so the payload is ambiguous.
JsonSerializer.Deserialize<Pet>("""{"Name":"Shared"}"""); // throws JsonException
JsonSerializer.Deserialize<Pet>("{}"); // throws JsonExceptionNon-object cases must use distinct JSON token categories:
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Value(int, string, bool, List<int>);
JsonSerializer.Deserialize<Value>("42"); // int
JsonSerializer.Deserialize<Value>("\"text\""); // string
JsonSerializer.Deserialize<Value>("true"); // bool
JsonSerializer.Deserialize<Value>("""[1,2,3]"""); // List<int>The classifier doesn't sniff string contents to distinguish primitive cases:
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Identifier(Guid, string);
// Both cases claim the JSON string category, regardless of the payload contents.
JsonSerializer.Deserialize<Identifier>("\"e8b7c2a0-...\""); // throws NotSupportedExceptionJsonNumberHandling.AllowReadingFromString likewise causes a numeric case to claim the string category. Combining it with another string case is rejected as ambiguous.
JsonUnmappedMemberHandling.Disallow disqualifies a case when the payload contains an undeclared property:
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Record(Closed, Open);
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed class Closed { public int Id { get; set; } public string? Tag { get; set; } }
public sealed class Open { public int Id { get; set; } }
JsonSerializer.Deserialize<Record>("""{"Id":1,"Tag":"a"}"""); // Closed
JsonSerializer.Deserialize<Record>("""{"Id":1,"Tag":"a","X":true}"""); // OpenA missing required property disqualifies a case:
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Account(Member, Guest);
public sealed class Member { public int Id { get; set; } public required string Name { get; set; } }
public sealed class Guest { public int Id { get; set; } }
JsonSerializer.Deserialize<Account>("""{"Id":1,"Name":"Ada"}"""); // Member
JsonSerializer.Deserialize<Account>("""{"Id":1}"""); // GuestStructurally indistinguishable cases are rejected when the classifier is built:
// Array elements aren't inspected, so these cases share the same category.
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union TwoLists(List<int>, List<string>);
JsonSerializer.Deserialize<TwoLists>("[]"); // throws NotSupportedException
// These object cases declare the same property-name set.
[JsonUnion(TypeClassifier = typeof(JsonUnionTypeStructuralClassifier))]
public union Temperature(Celsius, Fahrenheit);
public sealed class Celsius { public double Degrees { get; set; } }
public sealed class Fahrenheit { public double Degrees { get; set; } }
JsonSerializer.Deserialize<Temperature>("""{"Degrees":21}"""); // throws NotSupportedExceptionThe classifier can also be registered globally:
[JsonSourceGenerationOptions(TypeClassifiers = [typeof(JsonUnionTypeStructuralClassifier)])]
[JsonSerializable(typeof(Pet))]
internal partial class MyContext : JsonSerializerContext;Note
This proposal update was generated by GitHub Copilot.
Activity
DeagleGross commentedon Jul 8, 2026
Hey, thanks for designing the structural classifier Eirik! I've ran it on the currently tested cases in aspnetcore and had 4 failures out of 40 test cases. Not vouching for fixing those - just reporting as a design decision to make.
1.
charscalar case commits without content-validationThe classifier accepts
charon any String token, so it commits before checking whether the string is actually a single character. If a later case (e.g.string) could have matched, it never gets a chance; if no other case can match, the caller gets a misleading Char-specific error instead of the generic "no case matched" message.2. Nested unions — Number/String-shaped inner case
Inner union's converter reports no supported JSON tokens, so no token ever matches.
3. Polymorphic base case matches even when discriminator is missing
Payload with no
$typestill resolves to the abstract polymorphic base rather than throwing. Callers who rely on$typebeing present see silent degradation instead of a clear error.And unrelated behavior I found: Under
JsonSerializerDefaults.Web,NumberHandling.AllowReadingFromStringlets numeric-shaped strings match numeric scalar cases. If you want"42"to route to astringcase inunion(int, string), either declare the string case first, or turn offAllowReadingFromString. Should we respect theNumberHandlingat all when using this TypeClassifier?eiriktsarpalis commentedon Jul 17, 2026
@EgorBot -amd -osx_arm64 -commits 1056645,beb76b5488ba0c95149bec4829ffbbacaa939d5d
Note
This benchmark request was generated by GitHub Copilot.
bartonjs commentedon Jul 21, 2026
We felt that moving "Structural" later in the name added clarity.
There were some concerns voiced at the potential complexity of this feature given where we are in the release cycle, so the advice was to keep it simple in v1 (which happens to be v11).
[-][API Proposal]: JsonStructuralUnionTypeClassifier for structural union discrimination[/-][+][API Proposal]: JsonUnionTypeStructuralClassifier for structural union discrimination[/+][System.Text.Json] Add structural union type classifier (#132411)
[release/11.0-rc1] Add structural union type classifier (#132427)