Discriminated Json Subtypes Converter implementation for .NET
How to move between the packages and engines. Each section shows the same scenario before and after, and lists what actually changes in behaviour — not what would be nice to change.
The two packages share the same configuration model (attributes and JsonSubtypesConverterBuilder), so most code moves over line by line.
Before (Newtonsoft.Json):
[JsonConverter(typeof(JsonSubtypes), "Kind")]
[JsonSubtypes.KnownSubType(typeof(Dog), "Dog")]
[JsonSubtypes.KnownSubType(typeof(Cat), "Cat")]
public class Animal
{
public virtual string Kind { get; }
}
// registration / usage
JsonConvert.DeserializeObject<Animal>("{\"Kind\":\"Dog\",\"Breed\":\"Rex\"}");
After (System.Text.Json):
[JsonSubTypeConverter("Kind")]
[KnownSubType(typeof(Dog), "Dog")]
[KnownSubType(typeof(Cat), "Cat")]
public class Animal
{
public virtual string Kind { get; }
}
// registration / usage: options are required (STJ has no DefaultSettings)
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesConverterBuilder.Of<Animal>("Kind").Build());
JsonSerializer.Deserialize<Animal>("{\"Kind\":\"Dog\",\"Breed\":\"Rex\"}", options);
Mechanical differences:
[JsonConverter(typeof(JsonSubtypes), "Kind")] becomes [JsonSubTypeConverter("Kind")] — the converter is JsonSubtypes<T>, closed over the annotated type. The explicit [JsonSubTypeConverter(typeof(JsonSubtypes<Animal>), "Kind")] form is equivalent.[JsonSubtypes.KnownSubType] becomes [KnownSubType] (import JsonSubTypes.Text.Json).[JsonSubtypes.FallBackSubType] becomes [FallbackSubType] — the STJ spelling is Fallback, not FallBack, so the attribute must be renamed; the builder’s SetFallbackSubtype keeps the name it has on both sides.JsonConvert.SerializeObject/DeserializeObject become JsonSerializer.Serialize/Deserialize, and you must pass a JsonSerializerOptions (there is no equivalent of DefaultSettings).JsonSubtypesConverterBuilder.Of(...), RegisterSubtype, SerializeDiscriminatorProperty) is the same shape. JsonSubtypesWithPropertyConverterBuilder likewise.Behaviour that actually differs — check your tests against these:
CanWrite = false). If you relied on attributes for read-only, the JSON shape changes.[JsonProperty(Order = N)] support.MaxDepth needs one more level because the write path round-trips through a JsonDocument.[JsonIgnore], [JsonPropertyName], naming policy and DefaultIgnoreCondition, but not per-property [JsonConverter], [JsonInclude] fields, required members or parameterized constructors.JsonSubTypesTypeResolution.AddAssembly); Newtonsoft never supported them.The three engines share the configuration layer (attributes + builder), but they do not support the same feature set. Moving between them is usually a change of capability, not just a change of call.
Build()) → Resolver (BuildResolver())The resolver delegates to System.Text.Json native polymorphism. It is faster but refuses configuration it cannot express — at build time (NotSupportedException), not silently. Before moving, check that your scenario only uses:
null, no other value types),SetFallbackSubtype(baseType) maps to ignore-unrecognized; anything else throws).// before
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesConverterBuilder
.Of<Animal>("Kind").RegisterSubtype<Dog>("Dog").Build());
// after
var options = new JsonSerializerOptions
{
TypeInfoResolver = JsonSubtypesConverterBuilder
.Of<Animal>("Kind").RegisterSubtype<Dog>("Dog").BuildResolver()
};
Property-presence matching, dotted discriminator paths, enum/null discriminators and multi-level hierarchies are not supported by the resolver — keep the converter for those.
Build()) → Generator (JsonSubTypes.Text.Json.Aot)The generator reads its registrations from attributes at compile time and can only route types visible to the compilation. Move to it when the hierarchy is fixed and known at build time (or you publish with Native AOT / trimming), and reference both packages:
dotnet add package JsonSubTypes.Text.Json.Aot
dotnet add package JsonSubTypes.Text.Json
// before (builder)
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesConverterBuilder
.Of<Animal>("Kind").RegisterSubtype<Dog>("Dog").Build());
// after (attributes + generated converter)
[JsonSubTypesAotConverter("Kind")]
[KnownSubType(typeof(Dog), "Dog")]
public class Animal { }
var options = new JsonSerializerOptions
{
TypeInfoResolver = MyContext.Default,
Converters = { JsonSubTypesAotConverters.Animal }
};
[JsonSerializable(typeof(Animal))]
[JsonSerializable(typeof(Dog))]
public partial class MyContext : JsonSerializerContext { }
If you do not own the types (plugins, third-party assemblies) or the subtypes are only known at runtime, the generator cannot see them — keep the converter (or use RegisterDynamicSubtype where supported).
Both are compile-time friendly, but the generator supports strictly more features (enums, null, property presence, fallback subtypes, discriminator written last, nested hierarchies). If you outgrow the resolver’s subset, move to the generator rather than back to the converter — the configuration layer (attributes) is the same, so the change is mostly the call site.
System.Text.Json native polymorphism ([JsonDerivedType] + [JsonPolymorphic]) — that is a different API entirely, covered in the README’s System.Text.Json variant section.