JsonSubTypes is a discriminated Json sub-type Converter implementation for .NET
Status: Release Candidate. The
JsonSubTypes.Text.Jsonpackage is currently a release candidate (1.0.0-rc.x). The API and behavior are complete and tested (see below), but the stable1.0.0release will follow once the candidate has been validated by real-world usage.
A variant of the library for System.Text.Json (.NET 8+) is available in the JsonSubTypes.Text.Json namespace and package. It supports the same attribute-driven and builder-driven API, adapted to System.Text.Json idioms.
using JsonSubTypes.Text.Json;
[JsonSubTypeConverter(typeof(JsonSubtypes<Animal>), "Sound")]
[KnownSubType(typeof(Dog), "Bark")]
[KnownSubType(typeof(Cat), "Meow")]
public class Animal
{
public virtual string Sound { get; }
public string Color { get; set; }
}
public class Dog : Animal
{
public override string Sound { get; } = "Bark";
public string Breed { get; set; }
}
public class Cat : Animal
{
public override string Sound { get; } = "Meow";
public bool Declawed { get; set; }
}var animal = JsonSerializer.Deserialize<Animal>("{\"Sound\":\"Bark\",\"Breed\":\"Jack Russell Terrier\"}");
Assert.AreEqual("Jack Russell Terrier", (animal as Dog)?.Breed);Like the native [JsonDerivedType] polymorphism, the attribute-based converter handles both directions: serializing through the base type writes the discriminator, and deserialization reads it back, so round-trips work out of the box:
var json = JsonSerializer.Serialize<Animal>(new Dog { Breed = "Jack Russell Terrier" });
// {"Sound":"Bark","Breed":"Jack Russell Terrier"}
var back = JsonSerializer.Deserialize<Animal>(json);
Assert.IsInstanceOf<Dog>(back);When the runtime type is not declared in the [KnownSubType] mappings (e.g. a multi-level hierarchy where the leaf is registered on an intermediate base), serialization falls back to the plain runtime-type contract without a discriminator.
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(Animal), "type")
.RegisterSubtype(typeof(Cat), AnimalType.Cat)
.RegisterSubtype(typeof(Dog), AnimalType.Dog)
.Build());
var result = JsonSerializer.Deserialize<Animal>("{\"catLives\":6,\"type\":2,\"age\":11}", options);
Assert.AreEqual(typeof(Cat), result.GetType());The attribute-based converter writes the discriminator by default. For the builder, writing the discriminator is opt-in, like the Newtonsoft version:
options.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(Animal), "type")
.SerializeDiscriminatorProperty() // discriminator first (default)
// or .SerializeDiscriminatorProperty(false) // discriminator last
.RegisterSubtype(typeof(Cat), AnimalType.Cat)
.RegisterSubtype(typeof(Dog), AnimalType.Dog)
.Build());
var json = JsonSerializer.Serialize<Animal>(new Cat { Age = 11, Lives = 6 }, options);
// {"type":2,"catLives":6,"age":11}As with the native [JsonDerivedType] polymorphism, serialization must go through the base type (or a base-typed property/collection) for the converter and the discriminator to apply. Serializing a value with a concrete subtype as its static type bypasses the converter, and serializing an unregistered type throws when SerializeDiscriminatorProperty() is used.
[JsonSubTypeConverter(typeof(JsonSubtypes<Person>))]
[KnownSubTypeWithProperty(typeof(Employee), "JobTitle")]
[KnownSubTypeWithProperty(typeof(Artist), "Skill")]
public class Person { }[JsonSubTypeConverter(typeof(JsonSubtypes<IExpression>), "Type")]
[KnownSubType(typeof(ConstantExpression), "Constant")]
[FallBackSubType(typeof(UnknownExpression))]
public interface IExpression { }- The attribute-based converter writes the discriminator by default (like the native
[JsonDerivedType]polymorphism), whereas the Newtonsoft version never writes it from attributes (CanWrite = false). With the builder, writing is opt-in viaSerializeDiscriminatorProperty(). - With
System.Text.Json, the converter is only applied when the static type is the polymorphic base type (or a base-typed property/collection), matching the native[JsonDerivedType]behavior. The Newtonsoft version also applies converters when serializing a value whose static type is a concrete subtype. - A property declared with a base class or interface type is serialized using the declared type's contract: subtype members are omitted unless a converter that claims the declared type is applied (attribute on the type, or builder registered in
JsonSerializerOptions). The Newtonsoft version serialized the runtime type by default. - Property order differs:
System.Text.Jsonemits properties most-derived-first, while the Newtonsoft version honored[JsonProperty(Order = N)]. There is noOrdersupport inSystem.Text.Json. - Deeply nested graphs need
MaxDepthabout one level higher than with the Newtonsoft/plain serialization: the discriminator write path round-trips through aJsonDocument, which consumes one depth level. (A 64-level chain requiresMaxDepth = 66instead of 65.) - Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in:
JsonSubTypesTypeResolution.AddAssembly(...), a capability the Newtonsoft version does not have. JsonNamingPolicyandPropertyNameCaseInsensitiveare respected when matching the discriminator property, andJsonStringEnumConverteris respected when mapping discriminator values. Note thatJsonStringEnumConverter(.NET 8) does not honor[EnumMember(Value = ...)]β use enum names or[JsonStringEnumMemberName](.NET 9+).- Dotted or nested discriminator property paths (e.g.
"nested.property") are supported. - Fallback paths: serializing the base type itself (rather than a subtype) and deserializing an unknown discriminator back to the base use a reflection-based writer/reader, because the base type's contract is owned by the converter (
System.Text.Jsonexposes no property metadata for converter-owned types).[JsonPropertyName],[JsonIgnore](includingJsonIgnoreCondition), the naming policy andDefaultIgnoreConditionare honored; per-property[JsonConverter],[JsonInclude]fields,requiredmembers and parameterized constructors are not supported on these two paths. - Performance: writing an object with a discriminator serializes it once, then re-parses the JSON (
JsonDocument) to inject the discriminator property, so payloads spend roughly 2-3x their size in temporary memory on the write path. This is the cost of the converter architecture and of theMaxDepth + 1note above. - Security: name-based subtype resolution (
GetTypeByName, used when no[KnownSubType]mapping is declared) resolves a type name from the JSON discriminator against the base type's assembly (and any assembly registered viaJsonSubTypesTypeResolution). Only types assignable from the base can be resolved, but do not expose a name-based hierarchy to untrusted JSON without validating the payload upstream. - The property-presence builder (
JsonSubtypesWithPropertyConverterBuilder) registers subtypes by property name, so two subtypes cannot share the same property name through the builder (use[KnownSubTypeWithProperty]attributes for that case).
| Feature / Capability | Native STJ ([JsonDerivedType]) |
JsonSubTypes.Text.Json |
|---|---|---|
| Type discriminator mapping | β | β |
| Custom discriminator property name | β | β |
Property presence matching (KnownSubTypeWithProperty) |
β | β |
Fallback subtype (FallBackSubType) |
β | β |
| Cross-assembly / Plugin type resolution | β | β |
Dotted / nested discriminator path ("nested.type") |
β | β |
Opt-in discriminator writing (SerializeDiscriminatorProperty) |
β | β |
Seamless migration from Newtonsoft.Json JsonSubTypes |
β | β |
| Native AOT / Trimming support | β |
To preserve full compatibility with advanced features (KnownSubTypeWithProperty, nested discriminator paths, enum/null discriminators, cross-assembly resolution) while delegating 99% of object serialization to System.Text.Json, the library isolates base-type serialization to two narrow paths (when serializing the base type directly or reading an unregistered fallback type):
- Subtypes (99% of cases): Full delegation to
System.Text.Json. All STJ attributes ([JsonIgnore],[JsonInclude], property[JsonConverter],[JsonConstructor],recordtypes, naming policies) are fully supported natively. - Base-as-leaf & Fallback path: Handled via lightweight direct property mapping. Standard attributes (
[JsonIgnore],[JsonPropertyName],PropertyNamingPolicy,PropertyNameCaseInsensitive) are honored. Advanced member-level STJ attributes (e.g.[JsonInclude]on fields,[JsonConverter]on individual base properties, parameterized constructors) on the fallback base type itself are intentionally not re-implemented to avoid duplicate serializer engine complexity.
- Parameterless Constructor for Fallback: The base fallback type requires a parameterless constructor. Subtypes resolved via discriminator mapping support all STJ constructor features (primary constructors,
recordtypes). - Native AOT: Relies on reflection to discover subtypes; annotated with
[RequiresUnreferencedCode]and[RequiresDynamicCode].
| Use case | Recommended |
|---|---|
| Closed hierarchy, all subtypes known at compile time, string/int discriminator, round-trip serialization, Native AOT | Native [JsonDerivedType] / [JsonPolymorphic] (source-gen friendly) |
| Discriminator by property presence (no discriminator field in the JSON) | JsonSubTypes.Text.Json |
| Open hierarchies / subtypes registered at runtime | JsonSubTypes.Text.Json |
Non string/int discriminator values (enums, null, several values mapping to one type) |
JsonSubTypes.Text.Json |
Nested or dotted discriminator paths (e.g. "nested.property") |
JsonSubTypes.Text.Json |
| Resolution by .NET type name, or cross-assembly plugin subtypes | JsonSubTypes.Text.Json |
| Migrating an existing JsonSubTypes/Newtonsoft code base | JsonSubTypes.Text.Json (same API) |
[JsonConverter(typeof(JsonSubtypes), "Kind")]
public interface IAnimal
{
string Kind { get; }
}
public class Dog : IAnimal
{
public string Kind { get; } = "Dog";
public string Breed { get; set; }
}
public class Cat : IAnimal {
public string Kind { get; } = "Cat";
public bool Declawed { get; set;}
}The second parameter of the JsonConverter attribute is the JSON property name that will be use to retreive the type information from JSON.
var animal = JsonConvert.DeserializeObject<IAnimal>("{\"Kind\":\"Dog\",\"Breed\":\"Jack Russell Terrier\"}");
Assert.AreEqual("Jack Russell Terrier", (animal as Dog)?.Breed);N.B.: This only works for types in the same assembly as the base type/interface and either in the same namespace or with a fully qualified type name.
[JsonConverter(typeof(JsonSubtypes), "Sound")]
[JsonSubtypes.KnownSubType(typeof(Dog), "Bark")]
[JsonSubtypes.KnownSubType(typeof(Cat), "Meow")]
public class Animal
{
public virtual string Sound { get; }
public string Color { get; set; }
}
public class Dog : Animal
{
public override string Sound { get; } = "Bark";
public string Breed { get; set; }
}
public class Cat : Animal
{
public override string Sound { get; } = "Meow";
public bool Declawed { get; set; }
}var animal = JsonConvert.DeserializeObject<IAnimal>("{\"Sound\":\"Bark\",\"Breed\":\"Jack Russell Terrier\"}");
Assert.AreEqual("Jack Russell Terrier", (animal as Dog)?.Breed);N.B.: Also works with other kind of value than string, i.e.: enums, int, ...
This mode of operation only works when JsonSubTypes is explicitely registered in JSON.NET's serializer settings, and not through the [JsonConverter] attribute.
public abstract class Animal
{
public int Age { get; set; }
}
public class Dog : Animal
{
public bool CanBark { get; set; } = true;
}
public class Cat : Animal
{
public int Lives { get; set; } = 7;
}
public enum AnimalType
{
Dog = 1,
Cat = 2
}var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(Animal), "Type") // type property is only defined here
.RegisterSubtype(typeof(Cat), AnimalType.Cat)
.RegisterSubtype(typeof(Dog), AnimalType.Dog)
.SerializeDiscriminatorProperty() // ask to serialize the type property
.Build());or using syntax with generics:
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of<Animal>("Type") // type property is only defined here
.RegisterSubtype<Cat>(AnimalType.Cat)
.RegisterSubtype<Dog>(AnimalType.Dog)
.SerializeDiscriminatorProperty() // ask to serialize the type property
.Build());var cat = new Cat { Age = 11, Lives = 6 }
var json = JsonConvert.SerializeObject(cat, settings);
Assert.Equal("{\"Lives\":6,\"Age\":11,\"Type\":2}", json);
var result = JsonConvert.DeserializeObject<Animal>(json, settings);
Assert.Equal(typeof(Cat), result.GetType());
Assert.Equal(11, result.Age);
Assert.Equal(6, (result as Cat)?.Lives);[JsonConverter(typeof(JsonSubtypes))]
[JsonSubtypes.KnownSubTypeWithProperty(typeof(Employee), "JobTitle")]
[JsonSubtypes.KnownSubTypeWithProperty(typeof(Artist), "Skill")]
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
public class Employee : Person
{
public string Department { get; set; }
public string JobTitle { get; set; }
}
public class Artist : Person
{
public string Skill { get; set; }
}or using syntax with generics:
string json = "[{\"Department\":\"Department1\",\"JobTitle\":\"JobTitle1\",\"FirstName\":\"FirstName1\",\"LastName\":\"LastName1\"}," +
"{\"Department\":\"Department1\",\"JobTitle\":\"JobTitle1\",\"FirstName\":\"FirstName1\",\"LastName\":\"LastName1\"}," +
"{\"Skill\":\"Painter\",\"FirstName\":\"FirstName1\",\"LastName\":\"LastName1\"}]";
var persons = JsonConvert.DeserializeObject<IReadOnlyCollection<Person>>(json);
Assert.AreEqual("Painter", (persons.Last() as Artist)?.Skill);settings.Converters.Add(JsonSubtypesWithPropertyConverterBuilder
.Of(typeof(Person))
.RegisterSubtypeWithProperty(typeof(Employee), "JobTitle")
.RegisterSubtypeWithProperty(typeof(Artist), "Skill")
.Build());or
settings.Converters.Add(JsonSubtypesWithPropertyConverterBuilder
.Of<Person>()
.RegisterSubtypeWithProperty<Employee>("JobTitle")
.RegisterSubtypeWithProperty<Artist>("Skill")
.Build());[JsonConverter(typeof(JsonSubtypes))]
[JsonSubtypes.KnownSubType(typeof(ConstantExpression), "Constant")]
[JsonSubtypes.FallBackSubType(typeof(UnknownExpression))]
public interface IExpression
{
string Type { get; }
}Or with code configuration:
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(IExpression), "Type")
.SetFallbackSubtype(typeof(UnknownExpression))
.RegisterSubtype(typeof(ConstantExpression), "Constant")
.Build());settings.Converters.Add(JsonSubtypesWithPropertyConverterBuilder
.Of(typeof(IExpression))
.SetFallbackSubtype(typeof(UnknownExpression))
.RegisterSubtype(typeof(ConstantExpression), "Value")
.Build());If this project helped you save money or time or simply makes your life also easier, you can give me a cup of coffee =)