-
Notifications
You must be signed in to change notification settings - Fork 5.4k
Description
Note
This proposal was drafted with the help of an AI agent. Please review for accuracy and remove this notice once you're satisfied with the content.
Background and motivation
C# is introducing three interlinked language features for union-like types: unions, closed hierarchies, and closed enums. System.Text.Json needs OOTB support for these new kinds.
Closed hierarchies introduce a closed modifier that restricts derivation to the declaring assembly. The compiler emits [Closed] and [ClosedSubtype(typeof(Derived))] attributes on the base type. Today, STJ polymorphism requires explicit [JsonDerivedType] for every subtype — this is redundant for closed hierarchies where the compiler already knows the complete type set.
Union types are value-wrapping structs/classes ([Union] + IUnion) where each case is identified by a single-parameter constructor. They emulate TypeScript-style T1 | T2 unions. Unions don't carry natural discriminators — a union(Cat, Dog) holding a value doesn't record which constructor was used, and the same instance (up to equality) can be created via multiple constructors if the types overlap (e.g. union(Cat, Dog, Labrador) where Labrador : Dog).
Closed enums prevent creation of values outside declared members. STJ's numeric enum deserialization currently accepts any integer ((Color)999), which violates the closed contract.
Existing workarounds
Users can manually list all derived types via [JsonDerivedType], write custom JsonConverter<T> for unions, and validate enums in application code. These are verbose, error-prone, and defeat the purpose of compiler-enforced closedness.
Design Decisions
1. Closed Hierarchies: Opt-in Derived Type Inference
[Closed] alone does not automatically make a type polymorphic — that would be a semantic departure from open hierarchies which require explicit [JsonPolymorphic]. Instead, a new InferDerivedTypes property on [JsonPolymorphic] enables auto-discovery:
// Closed hierarchy — subtypes discovered from compiler-emitted [ClosedSubtype] attributes (fast path):
[Closed]
[ClosedSubtype(typeof(Dog))]
[ClosedSubtype(typeof(Cat))]
[JsonPolymorphic(InferDerivedTypes = true)]
public abstract class Animal { public string? Name { get; set; } }
public class Dog : Animal { public string? Breed { get; set; } }
public class Cat : Animal { public int Lives { get; set; } }
Animal dog = new Dog { Name = "Rex", Breed = "Lab" };
string json = JsonSerializer.Serialize(dog);
// {"$type":"Dog","Name":"Rex","Breed":"Lab"}Dual discovery path:
- Fast path: If
[ClosedSubtype]attributes exist on the type (compiler-emitted), use them directly — O(1). - Slow path: Otherwise, scan
baseType.Assembly.GetTypes()for direct subtypes — expensive but justified by explicit opt-in.
Discriminators: Use Type.Name with an optional TypeDiscriminatorNamingPolicy transform. Explicit [JsonDerivedType] annotations always take precedence over auto-inferred entries.
2. Union Types: No Discriminator, Structural Matching
Union types don't have natural discriminators because:
- Case types can overlap —
union(Cat, Dog, Labrador)whereLabrador : Dog IUnion.Valueis untaggedobject?— the union doesn't record which constructor was used- The C# proposal explicitly states: creation equivalence means the same value can arise from different constructors
Serialization: Write the Value directly using the best-matching case type's contract — no wrapper, no $type:
[Union]
public struct Pet : IUnion
{
public Pet(Dog value) => Value = value;
public Pet(Cat value) => Value = value;
public object? Value { get; }
}
Pet pet = new Pet(new Dog { Name = "Rex", Breed = "Lab" });
string json = JsonSerializer.Serialize(pet);
// {"Name":"Rex","Breed":"Lab"} — clean, no $typeDeserialization: Recursive structural matching without speculative deserialization. The algorithm scores each case type by comparing JSON structure against the type's property schema. A score is a (matched, unmatched) pair where:
- matched counts JSON properties (or elements) whose names correspond to declared properties on the candidate type, accumulated recursively through nested objects and arrays.
- unmatched counts JSON properties that don't correspond to any declared property on the candidate type (i.e. unknown properties).
The two-component score exists because matched alone can't distinguish a type that recognizes 3 out of 3 JSON properties from one that recognizes 3 out of 5 — the latter has 2 unmatched properties, suggesting a weaker fit. The winner is selected by highest matched count first, then lowest unmatched count as a tiebreaker, then declaration order.
For primitive JSON tokens (numbers, strings, booleans, null), scoring is binary: the candidate type is either compatible (1,0) or disqualified. The two-component score only becomes meaningful for JSON objects, where different candidate types may recognize different subsets of the JSON properties.
A score of (-1,-1) represents disqualification — the JSON value is structurally incompatible with the candidate type (e.g. a JSON number against an object type). If all candidates are disqualified, deserialization throws JsonException.
ScoreCaseType(reader, candidateType, options, depth) → (matched, unmatched)
NULL → (1,0) if nullable type; disqualified if non-nullable value type
Number → (1,0) if numeric type; disqualified otherwise
String → (1,0) if any string-compatible type (string, DateTime, Guid, TimeSpan,
Uri, char, byte[], enum, JsonElement); disqualified otherwise
Boolean → (1,0) if bool; disqualified otherwise
Array → Score all elements against collection element type; single incompatible element disqualifies
Object → For each JSON property: +1 matched if name is a known property (recurse into value),
+1 unmatched if unknown. Missing [JsonRequired] properties disqualify.
Winner → Highest matched → lowest unmatched → declaration order
The algorithm reads ahead against a checkpointed Utf8JsonReader instance. The winner is then deserialized from the original reader position.
3. Closed Enums: Deserialization Validation
For [Closed] enums, validate that integer values correspond to declared members during deserialization:
[Closed]
public enum Color { Red, Green, Blue }
JsonSerializer.Deserialize<Color>("1"); // OK → Color.Green
JsonSerializer.Deserialize<Color>("999"); // throws JsonException (not a defined member)String-mode deserialization (JsonStringEnumConverter) inherently rejects unknown names — no changes needed. The EnumConverter<T> is the shared converter used by both default numeric mode and JsonStringEnumConverter, so validation covers all paths.
4. Custom Classifiers: Extensible Type Classification
The prototype introduces a unified classifier infrastructure that works across both union types and polymorphic types. A classifier is a delegate Type? JsonTypeClassifier(ref Utf8JsonReader reader) — it receives a reader positioned at the start of a JSON value and returns the resolved type, or null if no match is found.
Architecture:
| Layer | Component | Description |
|---|---|---|
| Delegate | JsonTypeClassifier |
The core delegate type. Invoked for every deserialization call. |
| Context | JsonTypeClassifierContext |
Immutable metadata snapshot passed to factories: declaring type, candidate types (with optional discriminator values), discriminator property name. |
| Factory | JsonTypeClassifierFactory |
Abstract base for classifier factories. Receives JsonTypeClassifierContext + JsonSerializerOptions. Called once during JsonTypeInfo configuration. |
| Built-in structural | JsonStructuralClassifierFactory |
The default factory for [JsonUnion] types. Performs property-based structural matching. |
| Built-in discriminator | JsonDiscriminatorClassifierFactory |
A factory that scans for a named discriminator property (string or int). Reads all configuration from the context. Parameterizable analog of STJ's internal $type handling. |
| Attribute | [JsonPolymorphic(TypeClassifier = typeof(MyFactory))] |
Attaches a custom classifier to polymorphic types. Classifier is invoked before standard $type discriminator scanning. If it returns null, falls back to discriminator. |
| Attribute | [JsonUnion(TypeClassifier = typeof(MyFactory))] |
Attaches a custom classifier to union types. |
| Metadata | JsonTypeInfo.TypeClassifier |
Unified classifier property for both polymorphic and union types. Set programmatically via contract customization. |
Relationship to AllowOutOfOrderMetadataProperties: The existing AllowOutOfOrderMetadataProperties setting is effectively a special case of a discriminator classifier — it scans ahead in the JSON for $type regardless of property order. JsonDiscriminatorClassifierFactory generalizes this to any property name and any discriminator mapping, reading all configuration from the JsonTypeClassifierContext. Custom classifiers can compose with standard discriminator handling: if the classifier returns null, the standard $type resolution path is used as a fallback.
Deferred factory resolution: The TypeClassifier attribute property stores only the Type of the factory. Instantiation is deferred to PolymorphicTypeResolver construction, where JsonSerializerOptions and the full JsonTypeClassifierContext (derived types + discriminator property name) are available. This avoids accessing options during attribute processing and maintains the [DynamicallyAccessedMembers(PublicParameterlessConstructor)] contract through the entire chain.
Context object design: The JsonTypeClassifierContext is an immutable snapshot that carries all available metadata in a scenario-neutral way:
- For polymorphic types:
CandidateTypesare populated from[JsonDerivedType]attributes (with discriminator values),TypeDiscriminatorPropertyNameis"$type"or custom. - For union types:
CandidateTypesare populated from discovered case types (null discriminators),TypeDiscriminatorPropertyNameisnull.
This design future-proofs the factory interface — new metadata can be added to the context without changing the abstract method signature.
5. Compiler-Generated Type Exclusion
All derived type inference paths exclude types annotated with [CompilerGenerated] (System.Runtime.CompilerServices.CompilerGeneratedAttribute). The C# compiler emits this attribute on closure display classes, async state machines, and other synthetic types that should never participate in polymorphic serialization or union case discovery.
Affected paths:
- Assembly scan (slow path in
InferDerivedTypesFromMetadata) — filterstype.GetCustomAttribute<CompilerGeneratedAttribute>() is null - Implicit operator discovery — filters parameter types
- Constructor discovery — filters parameter types
[ClosedSubtype]attributes (fast path) — not filtered, since compiler-emitted attributes only reference types the user explicitly declared
6. Custom Converter Case Types
Union case types that use custom JsonConverter implementations are structurally opaque to the default structural classifier. Custom converters bypass the property-based serialization model (JsonTypeInfoKind.None), so the structural classifier cannot inspect their JSON shape.
- Serialization works: The deconstructor extracts the case value, and
JsonSerializer.Serializedelegates to the custom converter. - Deserialization is unreliable: The classifier scores custom-converter types as
(0, N)— they lose to any case type with property matches. If the JSON only matches the custom-converter case, the classifier either returnsnull(throws) or misclassifies. - Workaround: Provide a custom classifier factory that understands the custom converter's JSON shape via
[JsonUnion(TypeClassifier = typeof(...))]or contract customization.
7. JSON Schema Generation
Union types emit anyOf schemas (consistent with existing polymorphic anyOf handling). Each union case contributes a sub-schema:
{
"anyOf": [
{ "type": ["object", "null"], "properties": { "Name": { "type": ["string", "null"] }, "Breed": { "type": ["string", "null"] } } },
{ "type": ["object", "null"], "properties": { "Name": { "type": ["string", "null"] }, "Lives": { "type": "integer" } } }
]
}A type-hoisting optimization is applied: if all case schemas share the same JsonSchemaType, it is hoisted to the parent and removed from individual cases. anyOf was chosen over oneOf because union case types can overlap (e.g., Cat and Dog both have Name), and anyOf tolerates a JSON instance matching multiple sub-schemas.
Edge Cases and Ambiguity (Union Structural Matching)
The structural matching algorithm is inherently heuristic. The following categories of edge cases are documented, with examples sourced from the prototype test suite.
Primitive type ambiguity
union(int, long) or union(float, double) — both numeric types score identically (1,0). First-declared wins:
[Union]
public struct IntOrLongUnion : IUnion
{
public IntOrLongUnion(int value) => Value = value;
public IntOrLongUnion(long value) => Value = value;
public object? Value { get; }
}
IntOrLongUnion result = JsonSerializer.Deserialize<IntOrLongUnion>("42");
// result.Value is int (first declared), NOT longString type ambiguity
JSON has a single string token type, but C# has many types that serialize as JSON strings: string, DateTime, DateTimeOffset, Guid, TimeSpan, Uri, char, byte[], and enums. When a union contains multiple string-compatible case types, the structural matcher cannot distinguish between them from the JSON token alone.
[Union]
public struct StringOrDateTimeUnion : IUnion
{
public StringOrDateTimeUnion(string value) => Value = value;
public StringOrDateTimeUnion(DateTime value) => Value = value;
public object? Value { get; }
}
// Both string and DateTime are compatible with a JSON string token.
// First declared wins — same behavior as union(int, long) with a number token.
var result = JsonSerializer.Deserialize<StringOrDateTimeUnion>("\"2024-01-15T12:30:00\"");
Assert.IsType<string>(result.Value); // string is first declared
result = JsonSerializer.Deserialize<StringOrDateTimeUnion>("\"hello world\"");
Assert.IsType<string>(result.Value); // string is first declared — correct here tooWhy not content sniffing (try-parse)? One might consider attempting to parse the string value against each candidate format type — e.g. try DateTime.TryParse, then Guid.TryParse, etc. — to make a more informed selection. This approach has significant problems:
- Security: Content sniffing has a long history of causing vulnerabilities. MIME type sniffing is a well-documented class of web security bugs. String content sniffing in a serializer would create a similar attack surface — an attacker who controls JSON payloads could craft strings that parse as unexpected types, potentially bypassing validation or triggering different code paths.
- Fragility: String parsing is culture-dependent.
"12/01/2024"is valid as aDateTimein some cultures but not others."3.14"might parse as aTimeSpan(3 days, 14 hours) depending on the format provider. Results would be non-deterministic across machines. - Performance: Try-parsing every string value against every format-specific candidate type adds significant overhead. For large arrays of strings, this multiplies across all elements.
- Scope creep: STJ supports custom converters that can deserialize arbitrary types from strings. Content sniffing would need to account for these too, or create an inconsistency between built-in and custom converters.
- False positives: Many strings are accidentally valid in multiple formats.
"1"parses as aTimeSpan(1 day), achar, and abyte[](base64). Content sniffing doesn't eliminate ambiguity — it just shifts the tiebreaker from declaration order to parse-attempt order, which is even less predictable.
Recommendation: Treat all string-compatible types equally at score (1,0) and rely on declaration order, consistent with the behavior for numeric types. This is simple, predictable, and avoids the risks above. Unions with multiple string-compatible types (union(string, DateTime)) should be documented as inherently ambiguous, with declaration order as the tiebreaker.
Full array element scoring
Arrays are scored by iterating ALL elements (not just the first). This means a discriminating element anywhere in the array can influence the result:
[Union]
public struct DogOrCatArrayUnion : IUnion
{
public DogOrCatArrayUnion(Dog[] value) => Value = value;
public DogOrCatArrayUnion(Cat[] value) => Value = value;
public object? Value { get; }
}
// First element is ambiguous (only Name), but second has Breed → Dog[] wins
string json = """[{"Name":"Rex"},{"Name":"Fido","Breed":"Poodle"}]""";
DogOrCatArrayUnion result = JsonSerializer.Deserialize<DogOrCatArrayUnion>(json);
Assert.IsType<Dog[]>(result.Value);Arrays with null first elements are also handled correctly — the null element is compatible with reference-type element types, while the remaining elements provide discrimination signal.
Nested union awareness
When a case type is itself a union ([Union] + IUnion), the algorithm recursively evaluates JSON against the inner union's case types:
[Union]
public struct InnerUnion : IUnion
{
public InnerUnion(int value) => Value = value;
public InnerUnion(string value) => Value = value;
public object? Value { get; }
}
[Union]
public struct OuterUnion : IUnion
{
public OuterUnion(InnerUnion value) => Value = value;
public OuterUnion(bool value) => Value = value;
public object? Value { get; }
}
// 42 → InnerUnion's int case matches; OuterUnion wraps InnerUnion(42)
OuterUnion result = JsonSerializer.Deserialize<OuterUnion>("42");
Assert.IsType<InnerUnion>(result.Value);
Assert.IsType<int>(((InnerUnion)result.Value!).Value);
// true → bool is a direct case type of OuterUnion
result = JsonSerializer.Deserialize<OuterUnion>("true");
Assert.IsType<bool>(result.Value);Structurally identical types
Types with identical property schemas are indistinguishable. First-declared wins:
class Point2D { public double X { get; set; } public double Y { get; set; } }
class Complex { public double X { get; set; } public double Y { get; set; } }
// Both score identically for {"X":1.0,"Y":2.0} → Point2D (first declared) winsCommon ancestor unions
When case types share inherited properties, the algorithm correctly disambiguates when derived-specific properties are present, but falls back to declaration order when only shared properties appear:
// {"Name":"Rex","Breed":"Lab"} → DogWithAncestor wins (2 matched vs 1)
// {"Name":"Rex"} → All three tie → first declared winsExtension data and polymorphic case types
[JsonExtensionData]properties are excluded from known-property matching — the algorithm doesn't give credit for types that absorb unknown properties.$typediscriminators in JSON are counted as unknown properties, not recognized as metadata.
Open Questions
-
Is
[ClosedSubtype]the confirmed attribute design? The closed-hierarchies proposal mentions encoding subtypes as a TODO. How are generic subtypes represented? -
Union declaration order stability: Structural matching uses constructor declaration order as the final tiebreaker. Does the C# proposal guarantee stable ordering from
GetConstructors()? -
Should union structural matching be opt-in? Structural matching is inherently heuristic. Options:
- Automatic (current): Any
[Union]type gets structural matching OOTB. - Opt-in: Require
[JsonUnionOptions(DeserializationMode = Structural)]. Without it, only serialization works. - The argument for opt-in: ambiguous unions like
union(string, DateTime)can silently produce wrong results. The argument against: unions without deserialization are much less useful.
- Automatic (current): Any
-
String type ambiguity resolution: The current recommendation is to treat all string-compatible types equally (score 1,0) and use declaration order, same as numeric types. Content sniffing (try-parse) was considered and rejected due to security, fragility, and performance concerns (see edge case analysis above). Should this be the final design, or should unions with multiple string-compatible types be rejected at type-info creation time instead?
-
[Flags]+[Closed]enum interaction: Should validation accept bitwise combinations of declared members (e.g.,Read | Write= 3)? -
Union serialization fidelity: When serializing
union(Cat, Dog)holding aGoldenRetriever : Dog, we serialize usingDog's contract. GoldenRetriever-specific properties are lost. Is this the intended behavior? -
Generic type names in discriminators:
Type.Namefor generics producesSome`1. Should STJ strip arity suffixes?
API Proposal
New runtime types (System.Runtime.CompilerServices)
These are compiler-infrastructure types that will be emitted by the C# compiler. They are prerequisites for the STJ feature.
namespace System.Runtime.CompilerServices;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Enum, AllowMultiple = false, Inherited = false)]
public sealed class ClosedAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class ClosedSubtypeAttribute : Attribute
{
public ClosedSubtypeAttribute(Type subtypeType);
public Type SubtypeType { get; }
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class UnionAttribute : Attribute { }
public interface IUnion
{
object? Value { get; }
}Additions to System.Text.Json
namespace System.Text.Json.Serialization;
public sealed partial class JsonPolymorphicAttribute
{
// When true, automatically discovers derived types from [ClosedSubtype]
// attributes (fast path) or assembly scanning (slow path).
// Discriminators are inferred from Type.Name.
public bool InferDerivedTypes { get; set; }
// Optional naming policy applied to inferred discriminators.
// Only meaningful when InferDerivedTypes = true.
public JsonKnownNamingPolicy TypeDiscriminatorNamingPolicy { get; set; }
// Optional custom classifier factory for type classification.
// Invoked before standard $type discriminator scanning.
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type? TypeClassifier { get; set; }
}namespace System.Text.Json.Serialization;
// Marks a type as a union with convention-based case type discovery.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class JsonUnionAttribute : JsonAttribute
{
// Optional custom classifier factory for type classification.
public Type? TypeClassifier { get; set; }
}namespace System.Text.Json.Serialization;
// Core delegate: classifies a JSON payload to determine the target type.
public delegate Type? JsonTypeClassifier(ref Utf8JsonReader reader);
// Immutable context for classifier factories. Internal ctor — only STJ creates instances.
// Scenario-neutral: works for both polymorphic and union classification.
public sealed class JsonTypeClassifierContext
{
public Type DeclaringType { get; }
public IReadOnlyList<JsonDerivedType> CandidateTypes { get; }
public string? TypeDiscriminatorPropertyName { get; }
}
// Abstract factory for creating type classifiers.
public abstract class JsonTypeClassifierFactory
{
public abstract JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context,
JsonSerializerOptions options);
}
// Built-in: structural matching classifier (property-based scoring).
[RequiresDynamicCode("...")]
[RequiresUnreferencedCode("...")]
public class JsonStructuralClassifierFactory : JsonTypeClassifierFactory
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context,
JsonSerializerOptions options);
}
// Built-in: discriminator property scanning classifier factory.
// Reads property name and discriminator mappings from the context.
public class JsonDiscriminatorClassifierFactory : JsonTypeClassifierFactory
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context,
JsonSerializerOptions options);
}namespace System.Text.Json.Serialization.Metadata;
public partial class JsonPolymorphismOptions
{
public bool InferDerivedTypes { get; set; }
public JsonNamingPolicy? TypeDiscriminatorNamingPolicy { get; set; }
}
public sealed class JsonUnionCaseInfo
{
public JsonUnionCaseInfo(Type caseType);
public Type CaseType { get; }
}
public enum JsonTypeInfoKind
{
None = 0,
Object = 1,
Enumerable = 2,
Dictionary = 3,
Union = 4,
}
public abstract partial class JsonTypeInfo
{
// Unified classifier for both polymorphic and union types.
// For polymorphic types: overrides standard $type discriminator resolution.
// For union types: determines which case type matches the JSON payload.
public JsonTypeClassifier? TypeClassifier { get; set; }
public IList<JsonUnionCaseInfo>? UnionCases { get; set; }
public Func<object, (Type CaseType, object? CaseValue)>? UnionDeconstructor { get; set; }
public Func<Type, object?, object>? UnionConstructor { get; set; }
}
public sealed partial class JsonTypeInfo<T>
{
public new Func<T, (Type CaseType, object? CaseValue)>? UnionDeconstructor { get; set; }
public new Func<Type, object?, T>? UnionConstructor { get; set; }
}API Usage
Closed hierarchies — automatic derived type discovery
[Closed]
[ClosedSubtype(typeof(Dog))]
[ClosedSubtype(typeof(Cat))]
[JsonPolymorphic(InferDerivedTypes = true)]
public abstract class Animal { public string? Name { get; set; } }
public class Dog : Animal { public string? Breed { get; set; } }
public class Cat : Animal { public int Lives { get; set; } }
Animal dog = new Dog { Name = "Rex", Breed = "Lab" };
string json = JsonSerializer.Serialize(dog);
// {"$type":"Dog","Name":"Rex","Breed":"Lab"}
Animal roundTrip = JsonSerializer.Deserialize<Animal>(json)!;
// roundTrip is Dog ✓Closed hierarchies with naming policy
[JsonPolymorphic(InferDerivedTypes = true, TypeDiscriminatorNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public abstract class Animal { ... }
// Dog → "$type":"dog", Cat → "$type":"cat"Explicit [JsonDerivedType] takes precedence
[JsonPolymorphic(InferDerivedTypes = true)]
[JsonDerivedType(typeof(Dog), "doggo")] // explicit override
public abstract class Animal { ... }
// Dog → "$type":"doggo" (explicit), Cat → "$type":"Cat" (inferred)Union types — clean JSON, structural matching
[Union]
public struct Pet : IUnion
{
public Pet(Dog value) => Value = value;
public Pet(Cat value) => Value = value;
public object? Value { get; }
}
// Serialization: clean JSON, no $type wrapper
Pet pet = new Pet(new Dog { Name = "Rex", Breed = "Lab" });
string json = JsonSerializer.Serialize(pet);
// {"Name":"Rex","Breed":"Lab"}
// Deserialization: structural matching selects Dog (2 matched properties vs Cat's 1)
Pet roundTrip = JsonSerializer.Deserialize<Pet>(json)!;
Assert.IsType<Dog>(roundTrip.Value);Mixed primitive/object union
[Union]
public struct ResultUnion : IUnion
{
public ResultUnion(int value) => Value = value;
public ResultUnion(string value) => Value = value;
public object? Value { get; }
}
ResultUnion r1 = JsonSerializer.Deserialize<ResultUnion>("42"); // int
ResultUnion r2 = JsonSerializer.Deserialize<ResultUnion>("\"hello\""); // stringClosed enums — deserialization validation
[Closed]
public enum Color { Red, Green, Blue }
JsonSerializer.Deserialize<Color>("1"); // OK → Color.Green
JsonSerializer.Deserialize<Color>("999"); // throws JsonExceptionCustom classifier on polymorphic type
// A classifier factory that scans for a "kind" property instead of $type.
// Reads all configuration from the JsonTypeClassifierContext.
public class KindClassifierFactory : JsonTypeClassifierFactory
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
// Use the built-in discriminator classifier factory with a custom property name.
// Override the context's property name to "kind".
var kindContext = new JsonTypeClassifierContext(
context.DeclaringType, context.CandidateTypes, "kind");
return new JsonDiscriminatorClassifierFactory()
.CreateJsonClassifier(kindContext, options);
}
}
[JsonPolymorphic(TypeClassifier = typeof(KindClassifierFactory))]
[JsonDerivedType(typeof(Dog), "dog")]
[JsonDerivedType(typeof(Cat), "cat")]
public abstract class Animal { public string? Name { get; set; } }
// Classifier resolves type from "kind" property; falls back to $type if null
string json = """{"kind":"dog","Name":"Rex","Breed":"Lab"}""";
Animal animal = JsonSerializer.Deserialize<Animal>(json)!;
// animal is Dog ✓Custom classifier on union type
// For unions with custom converter case types, a custom classifier is needed
[JsonUnion(TypeClassifier = typeof(ApiResponseClassifier))]
public struct ApiResponse
{
public ApiResponse(SuccessPayload s) { ... }
public ApiResponse(ErrorPayload e) { ... } // ErrorPayload has [JsonConverter]
...
}
// The custom classifier knows about ErrorPayload's non-standard JSON format
public class ApiResponseClassifier : JsonTypeClassifierFactory
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
return (ref Utf8JsonReader reader) =>
{
Utf8JsonReader copy = reader;
if (copy.TokenType is JsonTokenType.StartObject)
{
while (copy.Read() && copy.TokenType is not JsonTokenType.EndObject)
{
if (copy.TokenType is JsonTokenType.PropertyName)
{
if (copy.ValueTextEquals("error_code"u8)) return typeof(ErrorPayload);
if (copy.ValueTextEquals("Data"u8)) return typeof(SuccessPayload);
copy.Read();
copy.TrySkip();
}
}
}
return null;
};
}
}Contract customization — programmatic classifier
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers =
{
typeInfo =>
{
if (typeInfo.Type == typeof(Animal))
{
// Set the classifier directly on JsonTypeInfo (shared for both polymorphic and union types)
typeInfo.TypeClassifier = (ref Utf8JsonReader reader) =>
{
// Custom classification logic
Utf8JsonReader copy = reader;
// ... scan for distinguishing properties ...
return typeof(Dog);
};
}
}
}
}
};Alternative Designs
- Automatic polymorphism for
[Closed]types: Rejected — semantic departure from the opt-in model of[JsonPolymorphic]. - Adding
$typeto union serialization: Rejected — unions lack natural discriminators and the language designers prefer clean JSON. - Speculative deserialization for unions (try each type, catch failures): Rejected — STJ silently ignores unknown properties by default, making try-catch unreliable. Structural matching is more precise and avoids exception-as-control-flow.
- First-element-only array scoring: Rejected — fails on
[null, ...], empty arrays, and arrays where only later elements disambiguate. Full element scoring is more correct despite the O(n) cost. - String type specificity scoring (scoring format-specific types like DateTime/Guid higher than plain string): Rejected — this is structural-only and doesn't validate content, so
"hello world"would be routed to DateTime and fail. Content sniffing (try-parse) was also rejected due to security, fragility, and performance concerns. All string-compatible types score equally; declaration order decides. - Separate classifier delegate types for unions vs polymorphic types: Rejected —
JsonTypeClassifieris the samedelegate Type? (ref Utf8JsonReader)for both. Unification simplifies the API surface and allows sharing classifier implementations between union and polymorphic contexts. oneOffor union schemas: Rejected —anyOfwas chosen because union case types can overlap (e.g.,CatandDogboth haveName).oneOfrequires exactly one sub-schema to match, which fails for overlapping types.anyOfis also consistent with existing polymorphic schema generation.- Eager classifier factory instantiation during attribute processing: Rejected — the factory needs
JsonSerializerOptionswhich isn't available during attribute processing. Deferred resolution inPolymorphicTypeResolveris the correct pattern.
Risks
- Source breaking: None — all new API surface, no overload changes.
- Binary breaking: None — additive only.
- Performance: Assembly scanning in
InferDerivedTypesslow path is O(n) over assembly types, but this only runs once duringJsonTypeInfoconfiguration. Union structural matching requires buffering of the entire JSON value in the context of streaming deserialization. Custom classifiers avoid the buffering cost when property-scanning is not needed.
Prototype
Branch: json-unions — prototype commit ee9aac6
The prototype implements reflection-based (DefaultJsonTypeInfoResolver) support for all features. 37 files changed, ~5900 insertions. Test coverage:
- 134 tests for derived type inference (closed hierarchies, assembly scanning, compiler-generated exclusion, custom converter interaction)
- 418 tests (38 methods × 11 serializer variants) for custom classifiers on polymorphic types
- 362 tests for JSON schema generation including union
anyOfschemas - 5,390 total polymorphic tests all passing alongside existing test suites
Hand-crafted stub types simulate compiler output since the C# compiler doesn't yet support these features.