Repository navigation
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.
Sub-issues
- [API Proposal]: System.Text.Json union type support #127299 — Union type support
- [API Proposal]: Add STJ support for closed hierarchies #129041 — Closed hierarchy support
- Closed enum support (issue pending C# compiler preview)
Background and motivation
C# is introducing three language features for union-like types: unions, closed hierarchies, and closed enums. System.Text.Json should support them out of the box.
- Unions use native syntax (
union Result(int, string)), backed by the sharedUnionAttribute/IUnionruntime support from Add UnionAttribute and IUnion interface to System.Runtime.CompilerServices #127001. They emulate TypeScript-styleT1 | T2: one of several case values, but no natural discriminator. - Closed hierarchies (
closed class Animal) restrict derivation to the declaring assembly. The compiler emits[Closed]on the base and[ClosedSubtype]for each derived type. - Closed enums (
closed enum Color) forbid values outside declared members.
Today users must list every derived type with [JsonDerivedType], write custom converters for unions, and validate enums in application code - verbose and error-prone.
Prerequisites (separate proposals)
- [API Proposal]: UnionAttribute and IUnion interface #126650 -
UnionAttribute/IUnionshipped in Add UnionAttribute and IUnion interface to System.Runtime.CompilerServices #127001. - Closed hierarchy attributes (
ClosedAttribute,ClosedSubtypeAttribute) - pending.
Design
1. Closed hierarchies: opt-in derived type inference
[Closed] does not automatically enable polymorphism - that would diverge from the opt-in model of [JsonPolymorphic]. Instead, a new InferDerivedTypes flag enables discovery:
[JsonPolymorphic(InferDerivedTypes = true)]
closed class Animal { public string? Name { get; set; } }
class Dog : Animal { public string? Breed { get; set; } }
class Cat : Animal { public int Lives { get; set; } }
JsonSerializer.Serialize<Animal>(new Dog { Name = "Rex", Breed = "Lab" });
// {"$type":"Dog","Name":"Rex","Breed":"Lab"}Discovery uses [ClosedSubtype] when present (O(1)) and falls back to assembly.GetTypes() otherwise. Discriminators default to Type.Name and can be reshaped via TypeDiscriminatorNamingPolicy. Explicit [JsonDerivedType] always takes precedence. Compiler-generated types ([CompilerGenerated]) are excluded from assembly scans.
2. Union types: token-type matching, no read-ahead
Case selection inspects reader.TokenType only - O(1), no buffering:
JsonTokenType |
Compatible types |
|---|---|
Number |
numeric primitives |
String |
string, DateTime, Guid, TimeSpan, Uri, char, byte[], enums |
True/False |
bool |
StartObject |
objects |
StartArray |
arrays, collections |
Null |
nullable types |
union Result(int, string) works out of the box. Unions whose case types share a token (union Pet(Cat, Dog), union Num(int, long)) are considered ambiguous: InvalidOperationException is thrown when the JsonTypeInfo is configured, and the source generator emits SYSLIB1227. Users resolve ambiguity with a custom classifier.
Serialization writes Value using the case type's contract - no wrapper, no $type.
Null handling. A union accepts null iff at least one case constructor parameter is declared nullable. Any number of cases may be declared nullable — union(int?, string?, Dog?) is well-formed and explicitly supported. On the Null token the converter short-circuits — any configured JsonTypeClassifier is bypassed — and dispatches directly to a nullable case's constructor with null. When more than one case is nullable, the first declared nullable case is chosen; by spec, passing null to any nullable case produces the same null-holding union value, so the choice is semantically transparent and round-trip equality is preserved. Per-case nullability is exposed on JsonUnionCaseInfo.IsNullable and surfaces in the schema as "type": ["…", "null"].
3. Custom classifiers
A classifier is a delegate that maps a JSON value to a case type:
public delegate Type? JsonTypeClassifier(ref Utf8JsonReader reader);The reader is a defensive copy positioned at the start of the value; classifiers may advance it freely. Returning null signals classification failure and surfaces as JsonException.
Factories construct classifiers during JsonTypeInfo configuration. The context object carries declaring type, candidate types, and (for polymorphism) the discriminator property name:
public abstract class JsonTypeClassifierFactory
{
public abstract bool CanClassify(Type declaringType);
public abstract JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options);
}
// Typed accelerator that seals CanClassify to t == typeof(T).
public abstract class JsonTypeClassifierFactory<T> : JsonTypeClassifierFactory
{
public sealed override bool CanClassify(Type declaringType) => declaringType == typeof(T);
}Classifier factories may be registered three ways:
- Per-type via attribute —
[JsonPolymorphic(TypeClassifier = typeof(MyFactory))]or[JsonUnion(TypeClassifier = typeof(MyFactory))]. The attribute holds only the factoryType; instantiation is deferred untilJsonTypeInfoconfiguration soJsonSerializerOptionsand the full context are in scope. Validation throws ifCanClassify(declaringType)returnsfalse. - Globally via
JsonSerializerOptions.Classifiers— for unions only. The first factory whoseCanClassifyreturnstruefor the union type is used. Per-type attribute settings take precedence over the options-level list. - Programmatically via
JsonTypeInfo.TypeClassifierin a contract resolver modifier.
For polymorphism, a custom classifier fully replaces standard $type resolution. No built-in classifier factories are proposed.
4. Closed enums
EnumConverter<T> validates that integer values correspond to declared members; string-mode deserialization already rejects unknown names. Covers both default numeric mode and JsonStringEnumConverter.
5. JSON schema
Unions emit anyOf with one sub-schema per case. If all cases share a JsonSchemaType, it hoists to the parent. anyOf (not oneOf) tolerates overlapping case schemas.
For example, union(string, int) produces:
{
"anyOf": [
{ "type": "string" },
{ "type": "integer" }
]
}Schema generation is classifier-invariant: the exporter walks JsonTypeInfo.UnionCases only and never invokes TypeClassifier. Installing, swapping, or removing a custom classifier does not change the generated schema. This mirrors how [JsonPolymorphic] schemas depend only on the registered DerivedTypes list, not on runtime discriminator resolution.
API proposal
namespace System.Text.Json.Serialization;
public sealed partial class JsonPolymorphicAttribute
{
public bool InferDerivedTypes { get; set; }
public JsonKnownNamingPolicy TypeDiscriminatorNamingPolicy { get; set; }
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type? TypeClassifier { get; set; }
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class JsonUnionAttribute : JsonAttribute
{
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type? TypeClassifier { get; set; }
}
public delegate Type? JsonTypeClassifier(ref Utf8JsonReader reader);
public sealed class JsonTypeClassifierContext
{
public Type DeclaringType { get; }
public IReadOnlyList<JsonDerivedType> CandidateTypes { get; }
public string? TypeDiscriminatorPropertyName { get; }
}
public abstract class JsonTypeClassifierFactory
{
public abstract bool CanClassify(Type declaringType);
public abstract JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options);
}
public abstract class JsonTypeClassifierFactory<T> : JsonTypeClassifierFactory
{
public sealed override bool CanClassify(Type declaringType) => declaringType == typeof(T);
}
public sealed partial class JsonSerializerOptions
{
// Mutable list of factories consulted (in order) when configuring a union type that
// has no per-type [JsonUnion(TypeClassifier = ...)]. The first factory whose
// CanClassify(unionType) returns true is used. Does not affect polymorphic types.
public IList<JsonTypeClassifierFactory> Classifiers { get; }
}
public sealed partial class JsonSourceGenerationOptionsAttribute
{
// Equivalent of JsonSerializerOptions.Classifiers for source-generated contexts.
// Each Type must derive from JsonTypeClassifierFactory and have a public parameterless ctor.
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type[]? Classifiers { get; set; }
}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 bool IsNullable { get; init; }
}
public enum JsonTypeInfoKind
{
None = 0,
Object = 1,
Enumerable = 2,
Dictionary = 3,
Union = 4,
}
public abstract partial class JsonTypeInfo
{
public JsonTypeClassifier? TypeClassifier { get; set; }
public IList<JsonUnionCaseInfo>? UnionCases { get; set; }
public Func<Type, object?, object>? UnionConstructor { get; set; }
public Func<object, (Type? CaseType, object? CaseValue)>? UnionDeconstructor { get; set; }
}
public sealed partial class JsonTypeInfo<T>
{
public new Func<Type, object?, T>? UnionConstructor { get; set; }
public new Func<T, (Type? CaseType, object? CaseValue)>? UnionDeconstructor { get; set; }
}
[EditorBrowsable(EditorBrowsableState.Never)]
public static partial class JsonMetadataServices
{
public static JsonTypeInfo<T> CreateUnionInfo<T>(JsonSerializerOptions options, JsonUnionInfoValues<T> unionInfo) where T : notnull;
}
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class JsonUnionInfoValues<T>
{
public IList<JsonUnionCaseInfo>? UnionCases { get; init; }
public Func<Type, object?, T>? UnionConstructor { get; init; }
public JsonTypeClassifier? TypeClassifier { get; init; }
public Func<T, (Type? CaseType, object? CaseValue)>? UnionDeconstructor { get; init; }
}API usage
Closed hierarchy with naming policy
[JsonPolymorphic(InferDerivedTypes = true, TypeDiscriminatorNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonDerivedType(typeof(Dog), "doggo")] // explicit entries win
closed class Animal { public string? Name { get; set; } }
// Dog -> "$type":"doggo", Cat -> "$type":"cat"Unambiguous union
union Result(int, string);
JsonSerializer.Deserialize<Result>("42"); // int
JsonSerializer.Deserialize<Result>("\"hello\""); // stringAmbiguous union: custom classifier via Utf8JsonReader
class Dog { public string? Name { get; set; } public string? Breed { get; set; } }
class Cat { public string? Name { get; set; } public int Lives { get; set; } }
public class PetClassifier : JsonTypeClassifierFactory<Pet>
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
// The reader is a defensive copy - safe to advance.
return static (ref Utf8JsonReader reader) =>
{
if (reader.TokenType is not JsonTokenType.StartObject) return null;
while (reader.Read() && reader.TokenType is not JsonTokenType.EndObject)
{
if (reader.TokenType is JsonTokenType.PropertyName)
{
if (reader.ValueTextEquals("Breed"u8)) return typeof(Dog);
if (reader.ValueTextEquals("Lives"u8)) return typeof(Cat);
reader.Read();
reader.TrySkip();
}
}
return null;
};
}
}
[JsonUnion(TypeClassifier = typeof(PetClassifier))]
union Pet(Cat, Dog);JsonNode-based classifier (simpler, slower)
public class JsonNodePetClassifier : JsonTypeClassifierFactory<Pet>
{
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
return static (ref Utf8JsonReader reader) =>
{
if (JsonNode.Parse(ref reader) is JsonObject obj)
{
if (obj.ContainsKey("Breed")) return typeof(Dog);
if (obj.ContainsKey("Lives")) return typeof(Cat);
}
return null;
};
}
}Precomputing in the factory
public class PropertyBasedClassifier : JsonTypeClassifierFactory
{
// Multivariate factory — applies to any union type. Override CanClassify
// to scope it to a specific set of types.
public override bool CanClassify(Type declaringType) => true;
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
var distinguishing = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
foreach (JsonDerivedType dt in context.CandidateTypes)
{
foreach (JsonPropertyInfo prop in options.GetTypeInfo(dt.DerivedType).Properties)
{
if (prop.Name is { } name)
distinguishing.TryAdd(name, dt.DerivedType);
}
}
return (ref Utf8JsonReader reader) =>
{
if (reader.TokenType is not JsonTokenType.StartObject) return null;
while (reader.Read() && reader.TokenType is not JsonTokenType.EndObject)
{
if (reader.TokenType is JsonTokenType.PropertyName &&
distinguishing.TryGetValue(reader.GetString()!, out Type? match))
return match;
reader.Read();
reader.TrySkip();
}
return null;
};
}
}Custom discriminator property on a polymorphic type
public class KindClassifierFactory : JsonTypeClassifierFactory
{
public override bool CanClassify(Type declaringType) => true;
public override JsonTypeClassifier CreateJsonClassifier(
JsonTypeClassifierContext context, JsonSerializerOptions options)
{
return static (ref Utf8JsonReader reader) =>
{
if (reader.TokenType is not JsonTokenType.StartObject) return null;
while (reader.Read() && reader.TokenType is not JsonTokenType.EndObject)
{
if (reader.TokenType is JsonTokenType.PropertyName && reader.ValueTextEquals("kind"u8))
{
reader.Read();
if (reader.TokenType is JsonTokenType.String)
{
return reader.GetString() switch
{
"dog" => typeof(Dog),
"cat" => typeof(Cat),
_ => null,
};
}
return null;
}
if (reader.TokenType is JsonTokenType.PropertyName)
{
reader.Read();
reader.TrySkip();
}
}
return null;
};
}
}
[JsonPolymorphic(TypeClassifier = typeof(KindClassifierFactory))]
[JsonDerivedType(typeof(Dog), "dog")]
[JsonDerivedType(typeof(Cat), "cat")]
public abstract class Animal { public string? Name { get; set; } }Source-generated union constructor / deconstructor
For compiler unions, source generation emits a dedicated CreateUnionInfo(...) contract with most-derived-first case ordering. Per-case nullability is recorded on JsonUnionCaseInfo.IsNullable and threads through both directions of the bridge:
class Dog;
class Lab : Dog;
class Bird;
union Pet(Dog, Lab, Bird?);Generated metadata:
var unionInfo = new JsonUnionInfoValues<Pet>
{
UnionCases = new JsonUnionCaseInfo[]
{
new JsonUnionCaseInfo(typeof(Lab)), // most-derived first
new JsonUnionCaseInfo(typeof(Dog)),
new JsonUnionCaseInfo(typeof(Bird)) { IsNullable = true }, // nullable case
},
UnionConstructor = static (Type _, object? value) => value switch
{
Lab caseValue0 => new Pet(caseValue0),
Dog caseValue1 => new Pet(caseValue1),
Bird caseValue2 => new Pet(caseValue2),
null => new Pet((Bird?)null), // null dispatches to the nullable case
},
UnionDeconstructor = static (Pet value) => value switch
{
Lab caseValue0 => (typeof(Lab), caseValue0),
Dog caseValue1 => (typeof(Dog), caseValue1),
Bird caseValue2 => (typeof(Bird), caseValue2),
null => (typeof(Bird), null), // null reports the nullable case type
},
};Closed enum
closed enum Color { Red, Green, Blue }
JsonSerializer.Deserialize<Color>("1"); // Color.Green
JsonSerializer.Deserialize<Color>("999"); // JsonExceptionOpen questions
- Are
[ClosedSubtype]'s shape and generic-subtype representation settled? - Does the C# proposal guarantee stable ordering from
GetConstructors()for union cases? - For
[Flags] closed enum, should validation accept bitwise combinations of declared members? - Should
Type.Namefor generics strip arity suffixes (Some`1->Some) when used as a discriminator?
Alternatives considered
- Automatic polymorphism for
[Closed]types - departs from the opt-in model of[JsonPolymorphic]. $typeon union serialization - unions lack natural discriminators.- Structural matching as default - requires O(n) read-ahead and masks genuinely ambiguous unions.
- Content sniffing for string ambiguity - culture-sensitive, security-risky, costly.
oneOffor union schemas - incompatible with overlapping case types.
Risks
- Breaking changes: none - all new surface.
- Performance: assembly scanning for
InferDerivedTypesis O(n) but runs once; default deserialization is O(1) with zero buffering; read-ahead is opt-in.
Prototype
Branch: json-unions - commit b11e3d00c23
Uses UnionAttribute/IUnion from System.Runtime.CompilerServices (#127001), with local stubs for [Closed]/[ClosedSubtype] pending their proposal. Covers reflection-based support, source generator with SYSLIB1227 for ambiguous unions, custom classifiers, JSON schema generation, and closed hierarchy/enum validation.
Activity
dotnet-policy-service commentedon Mar 11, 2026
Tagging subscribers to this area: @dotnet/area-system-text-json
See info in area-owners.md if you want to be subscribed.
Prototype STJ support for C# union-like types
Prototype STJ support for C# union-like types
Prototype STJ support for C# union-like types
[-][API Proposal]: System.Text.Json support for C# union types, closed hierarchies, and closed enums[/-][+]Add System.Text.Json support for C# union types, closed hierarchies, and closed enums[/+]Support open generics in polymorphic serialization in System.Text.Json (
Support open generics in polymorphic serialization in System.Text.Json (
Support open generics in polymorphic serialization in System.Text.Json (
CharlieDigital commentedon Aug 4, 2026
Question on this feature:
Given the following:
It produces an
objectresult type through the generated OpenAPI spec:Does this fall into the
Ambiguous union...case here?Is there an idiomatic way to handle this without implementing the
JsonTypeClassifierFactory?Edit:
This works as expected:
Producing the following schema:
eiriktsarpalis commentedon Aug 27, 2026
Closing as completed.