Skip to content

[API Proposal]: JsonUnionTypeStructuralClassifier for structural union discrimination #129805

Closed

Description

@eiriktsarpalis

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 JsonException

Non-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 NotSupportedException

JsonNumberHandling.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}""");   // Open

A 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}""");                // Guest

Structurally 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 NotSupportedException

The 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

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Jun 24, 2026
  2. added
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Jun 24, 2026
  3. added this to the 11.0.0 milestone on Jun 24, 2026
  4. removed
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Jun 24, 2026
  5. DeagleGross commented on Jul 8, 2026

    @DeagleGross
    Member

    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. char scalar case commits without content-validation

    The classifier accepts char on 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.

    public union UnionCharString(char, string);
    
    JsonSerializer.Deserialize<UnionCharString>("\"AB\"", opts);
    // throws: "The JSON value could not be converted to System.Char"
    // expected: routes to the string case ("AB")

    2. Nested unions — Number/String-shaped inner case

    Inner union's converter reports no supported JSON tokens, so no token ever matches.

    public union UnionInner(int, string);
    public union UnionOuter(UnionInner, bool);
    
    JsonSerializer.Deserialize<UnionOuter>("42", opts);
    // throws: "classifier for union type 'UnionOuter' returned null for JSON token type 'Number'"
    // expected: routes to UnionInner (Int32)
    
    JsonSerializer.Deserialize<UnionOuter>("\"x\"", opts);
    // throws: "classifier for union type 'UnionOuter' returned null for JSON token type 'String'"
    // expected: routes to UnionInner (String)

    3. Polymorphic base case matches even when discriminator is missing

    Payload with no $type still resolves to the abstract polymorphic base rather than throwing. Callers who rely on $type being present see silent degradation instead of a clear error.

    [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
    [JsonDerivedType(typeof(PolyCat), "cat")]
    [JsonDerivedType(typeof(PolyDog), "dog")]
    public record PolyAnimal();
    public record PolyCat(string Name) : PolyAnimal();
    public record PolyDog(string Breed) : PolyAnimal();
    public union UnionAnimalString(PolyAnimal, string);
    
    JsonSerializer.Deserialize<UnionAnimalString>("""{"Name":"x"}""", opts);
    // returns a PolyAnimal instance (base type), no throw
    // expected: either PolyCat via shape, or a clear "missing $type discriminator" error

    And unrelated behavior I found: Under JsonSerializerDefaults.Web, NumberHandling.AllowReadingFromString lets numeric-shaped strings match numeric scalar cases. If you want "42" to route to a string case in union(int, string), either declare the string case first, or turn off AllowReadingFromString. Should we respect the NumberHandling at all when using this TypeClassifier?

  6. added
    blockingMarks issues that we want to fast track in order to unblock other important work
    on Jul 13, 2026
  7. eiriktsarpalis commented on Jul 17, 2026

    @eiriktsarpalis
    MemberAuthor

    @EgorBot -amd -osx_arm64 -commits 1056645,beb76b5488ba0c95149bec4829ffbbacaa939d5d

    using System;
    using System.Text.Json;
    using System.Text.Json.Serialization;
    using BenchmarkDotNet.Attributes;
    using BenchmarkDotNet.Running;
    
    BenchmarkSwitcher.FromAssembly(typeof(StructuralUnionBenchmarks).Assembly).Run(args);
    
    [MemoryDiagnoser]
    public class StructuralUnionBenchmarks
    {
        private const string NumericJson = "42";
        private const string GuidJson = "\"0f8fad5b-d9cb-469f-a165-70867728950e\"";
        private const string VersionJson = "\"1.2.3.4\"";
        private const string TimeSpanJson = "\"01:02:03\"";
        private const string Base64Json = "\"AQIDBAUGBwg=\"";
        private const string ObjectJson = "{\"Name\":\"Rex\",\"Breed\":\"Labrador\"}";
    
        [Benchmark]
        public NumericUnion Numeric() => JsonSerializer.Deserialize<NumericUnion>(NumericJson);
    
        [Benchmark]
        public StringShapeUnion Guid() => JsonSerializer.Deserialize<StringShapeUnion>(GuidJson);
    
        [Benchmark]
        public StringShapeUnion Version() => JsonSerializer.Deserialize<StringShapeUnion>(VersionJson);
    
        [Benchmark]
        public StringShapeUnion TimeSpan() => JsonSerializer.Deserialize<StringShapeUnion>(TimeSpanJson);
    
        [Benchmark]
        public ByteArrayOrStringUnion Base64() => JsonSerializer.Deserialize<ByteArrayOrStringUnion>(Base64Json);
    
        [Benchmark]
        public PetUnion Object() => JsonSerializer.Deserialize<PetUnion>(ObjectJson);
    }
    
    [JsonUnion(TypeClassifier = typeof(JsonStructuralUnionTypeClassifier))]
    public union NumericUnion(int, long);
    
    [JsonUnion(TypeClassifier = typeof(JsonStructuralUnionTypeClassifier))]
    public union StringShapeUnion(Guid, Version, TimeSpan, string);
    
    [JsonUnion(TypeClassifier = typeof(JsonStructuralUnionTypeClassifier))]
    public union ByteArrayOrStringUnion(byte[], string);
    
    [JsonUnion(TypeClassifier = typeof(JsonStructuralUnionTypeClassifier))]
    public union PetUnion(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; }
    }

    Note

    This benchmark request was generated by GitHub Copilot.

  8. bartonjs commented on Jul 21, 2026

    @bartonjs
    Member

    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).

    namespace System.Text.Json.Serialization;
    
    public class JsonUnionTypeStructuralClassifier : JsonTypeClassifierFactory
    {
        public JsonUnionTypeStructuralClassifier();
    }
  9. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Jul 21, 2026
  10. changed the title [-][API Proposal]: JsonStructuralUnionTypeClassifier for structural union discrimination[/-] [+][API Proposal]: JsonUnionTypeStructuralClassifier for structural union discrimination[/+] on Jul 23, 2026
  11. added a commit that references this issue on Aug 17, 2026
    cca6cd6
  12. locked and limited conversation to collaborators on Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions