You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sub-issue of #125449, scoped to the closed-hierarchy slice. Replaces the "Closed hierarchy support" tracking bullet on the parent.
Background and motivation
C# is adding closed class hierarchies: a closed base whose derivation is restricted to the declaring assembly. The compiler stamps such a base with [IsClosedType(DerivedTypes = [...])] (#129009) so consumers can enumerate the closed set without scanning metadata.
System.Text.Json already supports polymorphism, but the derived-type list must be written by hand:
[JsonPolymorphic][JsonDerivedType(typeof(Dog),"dog")][JsonDerivedType(typeof(Cat),"cat")][JsonDerivedType(typeof(Hamster),"hamster")]closed class Animal {/* ... */}
For a closed hierarchy this duplicates information the compiler already records. As the hierarchy grows the two lists drift, and forgetting an entry yields silent runtime errors (NotSupportedException on serialize, missing-case exceptions on deserialize). A focused opt-in lets the resolver take the list straight from the compiler's metadata.
API Proposal
namespaceSystem.Text.Json.Serialization;publicsealedpartialclassJsonPolymorphicAttribute:JsonAttribute{// EXISTING// public string? TypeDiscriminatorPropertyName { get; set; }// public JsonUnknownDerivedTypeHandling UnknownDerivedTypeHandling { get; set; }// public bool IgnoreUnrecognizedTypeDiscriminators { get; set; }publicboolInferDerivedTypes{get;set;}}
Semantics when InferDerivedTypes = true:
The reflection-based resolver and the source generator read [IsClosedType].DerivedTypes on the annotated base and add one JsonDerivedType entry per applicable derived type, with a synthesized string discriminator of DerivedType.Name. (This is not equivalent to writing [JsonDerivedType(typeof(T))] with no discriminator argument, which leaves the discriminator null and therefore emits no $type — inference deliberately synthesizes one so the resulting payloads round-trip out of the box.)
Explicit [JsonDerivedType] declarations always win, both for inclusion and for discriminator naming. Inference only fills the gap for derived types the author didn't list.
Open generic entries in [IsClosedType].DerivedTypes (legal in closed hierarchies, e.g. class Wrapped<T> : Base<T> or class D3 : Base<string> under closed class Base<T>) are resolved per closed base instantiation using the unification logic added by #127318.
Discriminator collisions: if two inferred derived types yield the same Type.Name (e.g. MyApp.Models.Cat and MyApp.Other.Cat), the existing duplicate-discriminator validation in JsonPolymorphismOptions fires — InvalidOperationException at configure time, source-gen diagnostic at build time. The user resolves it by adding an explicit [JsonDerivedType] for one or both of them. No silent renaming.
Naming edge cases: DerivedType.Name follows standard reflection: nested types appear as their inner name (Inner, not Outer+Inner), generic types include the arity suffix (List`1 for List<int>). The latter is a known wart and is called out as Open Question Define a root README.md #3 below — the parent issue surfaces the same question.
Setting InferDerivedTypes = true on a base that lacks [IsClosedType] throws InvalidOperationException (resolver) and emits a new source-generator diagnostic (SYSLIB1230 — exact number TBD). Both paths fail loudly; there is no silent no-op or assembly.GetTypes() fallback. This keeps reflection and source-gen behavior aligned and preserves the "the compiler-emitted list is the single source of truth" invariant.
No counterpart property is added to JsonPolymorphismOptions. Inference runs at JsonTypeInfo materialization time inside the resolver/source generator and surfaces as ordinary DerivedTypes entries; JsonPolymorphismOptions is the result, not the place to express the policy. Custom IJsonTypeInfoResolver implementations that build JsonPolymorphismOptions programmatically can perform the same inference themselves and emit DerivedTypes entries directly — no API change is required to support that.
Dependencies
This proposal is implementable only once both of the following are in:
#129009 — IsClosedTypeAttribute.DerivedTypes. The compiler-emitted list is the only source of truth used by this feature. Without it, the resolver would have to fall back to assembly.GetTypes() scans, which is expensive and produces different results from the closed-hierarchy semantics the language guarantees. The proposal there is currently api-ready-for-review (not yet approved); its final shape may shift, and any change to the property name, nullability, or shape ripples directly into this design.
#127318 — Open-generic derived-type support in polymorphic serialization. Closed hierarchies are allowed to declare generic base types and generic derived types (e.g. closed class Base<T> with class Derived<T> : Base<T>). [IsClosedType].DerivedTypes lists the open derived type once, and the resolver needs to construct the closed instantiation for each serialized Base<X>. That unification step is exactly what Support open generics in polymorphic serialization in System.Text.Json #127318 adds; without it, InferDerivedTypes would either silently skip every generic derived type or throw at the first generic instantiation. Note that this proposal does not reuse Support open generics in polymorphic serialization in System.Text.Json #127318's explicit-attribute failure semantics wholesale — see the "not applicable" vs. "malformed" distinction in Semantics above.
No prototype is included because both dependencies are still in flight. Building one against local stubs would have to invent the exact attribute shape and unification behavior that are still under review, and the prototype would need to be redone once the dependencies land. The API surface here is one boolean property, so reviewers can evaluate the design without a working sample.
Mixing inferred and explicit entries — the explicit name wins, the rest are inferred:
[JsonPolymorphic(InferDerivedTypes=true)][JsonDerivedType(typeof(Dog),"doggo")]publicclosedclass Animal {publicrequiredstringName{get;set;}}// Dog -> "$type":"doggo"// Cat -> "$type":"Cat"
Alternative Designs
Should this flag exist at all?
The load-bearing open question for review. Three positions worth weighing:
Opt-in flag (this proposal).closed keeps its current semantic (exhaustive pattern matching, single-assembly derivation) and serialization stays explicit. Users who want polymorphism opt in with one flag. Pros: no wire-format surprises; matches the existing opt-in feel of [JsonPolymorphic]. Cons: still two lines of attributes to write ([JsonPolymorphic(InferDerivedTypes = true)]); not "batteries included".
Automatic — closed types are always polymorphic. Drop the flag; every closed type gains a $type discriminator when serialized via STJ. Pros: zero ceremony; matches the "discriminated unions just work" experience users expect from other ecosystems. Cons:
Couples a serialization-meaningful semantic to a keyword whose primary purpose is exhaustive pattern matching. A library author who adopted closed for pattern-matching reasons would silently start emitting $type payloads.
No clean opt-out. The only escape would be a negative attribute ([JsonNonPolymorphic] or similar) which doesn't exist today.
Wire-format-affecting default change, even if the type is brand new.
Tri-state default that depends on the underlying kind. Make inference default to on for [IsClosedType] bases and off otherwise. Pros: gives the "batteries included" feel for closed types while leaving the existing opt-in shape for plain [JsonPolymorphic]. Cons:
Not implementable as a bool property on the attribute. After GetCustomAttribute, reflection can't distinguish "unset" from "explicitly set to false", so a kind-defaulted policy needs a different shape — most naturally an enum (JsonInferDerivedTypesBehavior { Default, Enabled, Disabled }) or a nullable wrapper. That's a wider surface than this proposal aims for.
A property whose default depends on metadata on the declaring type is hard to document and surprising to read; users still need to know the policy exists to override it.
Tentative answer: ship the explicit opt-in (option 1). The closed keyword's primary user-facing purpose is exhaustive pattern matching, not serialization; quietly attaching wire-format consequences to it is a steep cost for one fewer line of attributes. Revisit option 2 or 3 once the broader picture (unions, [Closed] enums, JsonUnionAttribute) is settled and we can reason about the family of behaviors holistically rather than slice by slice.
Other alternatives considered
Reuse JsonPolymorphic alone, no flag — infer whenever the base has [IsClosedType]. Same wire-format-coupling concerns as option 2, just gated behind [JsonPolymorphic] instead of the keyword. Doesn't save any user typing over option 1 and is harder to discover.
Take a parameter on [JsonPolymorphic] instead of a property ([JsonPolymorphic(inferDerivedTypes: true)]). Existing JsonPolymorphicAttribute uses named properties exclusively (TypeDiscriminatorPropertyName, UnknownDerivedTypeHandling, IgnoreUnrecognizedTypeDiscriminators); adding the first constructor parameter would be inconsistent.
Prototype
Not provided. See Dependencies above — both #127318 and #129009 must land before a representative prototype can be built. The proposal is one boolean property and is reviewable on the design alone.
Sub-issue of #125449, scoped to the closed-hierarchy slice. Replaces the "Closed hierarchy support" tracking bullet on the parent.
Background and motivation
C# is adding closed class hierarchies: a
closedbase whose derivation is restricted to the declaring assembly. The compiler stamps such a base with[IsClosedType(DerivedTypes = [...])](#129009) so consumers can enumerate the closed set without scanning metadata.System.Text.Jsonalready supports polymorphism, but the derived-type list must be written by hand:For a
closedhierarchy this duplicates information the compiler already records. As the hierarchy grows the two lists drift, and forgetting an entry yields silent runtime errors (NotSupportedExceptionon serialize, missing-case exceptions on deserialize). A focused opt-in lets the resolver take the list straight from the compiler's metadata.API Proposal
Semantics when
InferDerivedTypes = true:[IsClosedType].DerivedTypeson the annotated base and add oneJsonDerivedTypeentry per applicable derived type, with a synthesized string discriminator ofDerivedType.Name. (This is not equivalent to writing[JsonDerivedType(typeof(T))]with no discriminator argument, which leaves the discriminatornulland therefore emits no$type— inference deliberately synthesizes one so the resulting payloads round-trip out of the box.)[JsonDerivedType]declarations always win, both for inclusion and for discriminator naming. Inference only fills the gap for derived types the author didn't list.[IsClosedType].DerivedTypes(legal in closed hierarchies, e.g.class Wrapped<T> : Base<T>orclass D3 : Base<string>underclosed class Base<T>) are resolved per closed base instantiation using the unification logic added by #127318.Type.Name(e.g.MyApp.Models.CatandMyApp.Other.Cat), the existing duplicate-discriminator validation inJsonPolymorphismOptionsfires —InvalidOperationExceptionat configure time, source-gen diagnostic at build time. The user resolves it by adding an explicit[JsonDerivedType]for one or both of them. No silent renaming.DerivedType.Namefollows standard reflection: nested types appear as their inner name (Inner, notOuter+Inner), generic types include the arity suffix (List`1forList<int>). The latter is a known wart and is called out as Open Question Define a root README.md #3 below — the parent issue surfaces the same question.InferDerivedTypes = trueon a base that lacks[IsClosedType]throwsInvalidOperationException(resolver) and emits a new source-generator diagnostic (SYSLIB1230— exact number TBD). Both paths fail loudly; there is no silent no-op orassembly.GetTypes()fallback. This keeps reflection and source-gen behavior aligned and preserves the "the compiler-emitted list is the single source of truth" invariant.JsonPolymorphismOptions. Inference runs atJsonTypeInfomaterialization time inside the resolver/source generator and surfaces as ordinaryDerivedTypesentries;JsonPolymorphismOptionsis the result, not the place to express the policy. CustomIJsonTypeInfoResolverimplementations that buildJsonPolymorphismOptionsprogrammatically can perform the same inference themselves and emitDerivedTypesentries directly — no API change is required to support that.Dependencies
This proposal is implementable only once both of the following are in:
IsClosedTypeAttribute.DerivedTypes. The compiler-emitted list is the only source of truth used by this feature. Without it, the resolver would have to fall back toassembly.GetTypes()scans, which is expensive and produces different results from the closed-hierarchy semantics the language guarantees. The proposal there is currentlyapi-ready-for-review(not yet approved); its final shape may shift, and any change to the property name, nullability, or shape ripples directly into this design.closed class Base<T>withclass Derived<T> : Base<T>).[IsClosedType].DerivedTypeslists the open derived type once, and the resolver needs to construct the closed instantiation for each serializedBase<X>. That unification step is exactly what Support open generics in polymorphic serialization in System.Text.Json #127318 adds; without it,InferDerivedTypeswould either silently skip every generic derived type or throw at the first generic instantiation. Note that this proposal does not reuse Support open generics in polymorphic serialization in System.Text.Json #127318's explicit-attribute failure semantics wholesale — see the "not applicable" vs. "malformed" distinction in Semantics above.No prototype is included because both dependencies are still in flight. Building one against local stubs would have to invent the exact attribute shape and unification behavior that are still under review, and the prototype would need to be redone once the dependencies land. The API surface here is one boolean property, so reviewers can evaluate the design without a working sample.
API Usage
Mixing inferred and explicit entries — the explicit name wins, the rest are inferred:
Alternative Designs
Should this flag exist at all?
The load-bearing open question for review. Three positions worth weighing:
Opt-in flag (this proposal).
closedkeeps its current semantic (exhaustive pattern matching, single-assembly derivation) and serialization stays explicit. Users who want polymorphism opt in with one flag. Pros: no wire-format surprises; matches the existing opt-in feel of[JsonPolymorphic]. Cons: still two lines of attributes to write ([JsonPolymorphic(InferDerivedTypes = true)]); not "batteries included".Automatic —
closedtypes are always polymorphic. Drop the flag; everyclosedtype gains a$typediscriminator when serialized via STJ. Pros: zero ceremony; matches the "discriminated unions just work" experience users expect from other ecosystems. Cons:closedfor pattern-matching reasons would silently start emitting$typepayloads.[JsonNonPolymorphic]or similar) which doesn't exist today.Tri-state default that depends on the underlying kind. Make inference default to on for
[IsClosedType]bases and off otherwise. Pros: gives the "batteries included" feel for closed types while leaving the existing opt-in shape for plain[JsonPolymorphic]. Cons:boolproperty on the attribute. AfterGetCustomAttribute, reflection can't distinguish "unset" from "explicitly set tofalse", so a kind-defaulted policy needs a different shape — most naturally an enum (JsonInferDerivedTypesBehavior { Default, Enabled, Disabled }) or a nullable wrapper. That's a wider surface than this proposal aims for.Tentative answer: ship the explicit opt-in (option 1). The
closedkeyword's primary user-facing purpose is exhaustive pattern matching, not serialization; quietly attaching wire-format consequences to it is a steep cost for one fewer line of attributes. Revisit option 2 or 3 once the broader picture (unions,[Closed]enums,JsonUnionAttribute) is settled and we can reason about the family of behaviors holistically rather than slice by slice.Other alternatives considered
JsonPolymorphicalone, no flag — infer whenever the base has[IsClosedType]. Same wire-format-coupling concerns as option 2, just gated behind[JsonPolymorphic]instead of the keyword. Doesn't save any user typing over option 1 and is harder to discover.[JsonPolymorphic]instead of a property ([JsonPolymorphic(inferDerivedTypes: true)]). ExistingJsonPolymorphicAttributeuses named properties exclusively (TypeDiscriminatorPropertyName,UnknownDerivedTypeHandling,IgnoreUnrecognizedTypeDiscriminators); adding the first constructor parameter would be inconsistent.Prototype
Not provided. See Dependencies above — both #127318 and #129009 must land before a representative prototype can be built. The proposal is one boolean property and is reviewable on the design alone.