Skip to content

[API Proposal]: Add STJ support for closed hierarchies #129041

@eiriktsarpalis

Description

@eiriktsarpalis

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

namespace System.Text.Json.Serialization;

public sealed partial class JsonPolymorphicAttribute : JsonAttribute
{
    // EXISTING
    // public string? TypeDiscriminatorPropertyName { get; set; }
    // public JsonUnknownDerivedTypeHandling UnknownDerivedTypeHandling { get; set; }
    // public bool IgnoreUnrecognizedTypeDiscriminators { get; set; }

    public bool InferDerivedTypes { 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:

  • #129009IsClosedTypeAttribute.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.

API Usage

[JsonPolymorphic(InferDerivedTypes = true)]
public closed class Animal { public required string Name { get; set; } }

public class Dog : Animal { public required string Breed { get; set; } }
public class Cat : Animal { public int Lives { get; set; } }

string json = JsonSerializer.Serialize<Animal>(new Dog { Name = "Rex", Breed = "Lab" });
// {"$type":"Dog","Name":"Rex","Breed":"Lab"}

Mixing inferred and explicit entries — the explicit name wins, the rest are inferred:

[JsonPolymorphic(InferDerivedTypes = true)]
[JsonDerivedType(typeof(Dog), "doggo")]
public closed class Animal { public required string Name { 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:

  1. 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".

  2. 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.
  3. 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.

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions