Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

479 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

JsonSubTypes

JsonSubTypes is a discriminated Json sub-type Converter implementation for .NET

CI CodeQL Code Coverage Quality Gate Status NuGet NuGet CodeFactor FOSSA Status

Note: this library is built around Json.NET/Newtonsoft.Json — that is where its API and reputation come from, and the JsonSubTypes NuGet package targets it. A System.Text.Json port exists as the JsonSubTypes.Text.Json package (.NET 8+): it shares the same API but is experimental. Full documentation, differences and known limitations are in the dedicated section at the bottom: System.Text.Json variant.

DeserializeObject with custom type property name

[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.

DeserializeObject with custom type mapping

[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, ...

SerializeObject and DeserializeObject with custom type property only present in JSON

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
}

Registration:

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());

De-/Serialization:

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);

DeserializeObject mapping by property presence

[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);

Registration:

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());

A default class other than the base type can be defined

[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());

System.Text.Json variant

Status: experimental. The JsonSubTypes.Text.Json package is a release candidate (1.0.0-rc.x) and not yet part of the project's stable offering. The code is fully tested (133 unit tests) and the API is complete, but the stable 1.0.0 release will follow once the package has been exercised in more real-world projects.

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.

Attribute based discriminator

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.

Builder based dynamic registration

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());

Native resolver via BuildResolver()

JsonSubtypesConverterBuilder also exposes the native System.Text.Json polymorphic contract model (JsonPolymorphismOptions) as an alternative to Build(). Assign the result to JsonSerializerOptions.TypeInfoResolver instead of Converters:

var options = new JsonSerializerOptions
{
    TypeInfoResolver = JsonSubtypesConverterBuilder
        .Of(typeof(Animal), "type")
        .RegisterSubtype(typeof(Cat), AnimalType.Cat)
        .RegisterSubtype(typeof(Dog), AnimalType.Dog)
        .SerializeDiscriminatorProperty()
        .BuildResolver()
};

The resolver delegates all serialization work to System.Text.Json, so it only supports a subset of the converter configuration and throws at build time otherwise: string or int discriminator values, a single level of hierarchy per base type, and the discriminator always written first. The following native behaviors are exposed as opt-in builder methods:

  • FallBackToNearestAncestor(): an unregistered derived type is serialized as its nearest registered ancestor instead of throwing.
  • IgnoreUnrecognizedTypeDiscriminators(): an unknown type discriminator falls back to the base type instead of throwing. SetFallbackSubtype(baseType) enables the same behavior.
  • When no subtype is registered explicitly, [KnownSubType] and [FallBackSubType] attributes on the base type are honored.

For several base type hierarchies, combine builders with JsonSubtypesConverterBuilder.BuildResolvers(...). Combining resolvers through JsonSerializerOptions.TypeInfoResolverChain does not work, because each resolver answers for every type and only the first one would be applied.

Serializing the discriminator

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.

Mapping by property presence

[JsonSubTypeConverter(typeof(JsonSubtypes<Person>))]
[KnownSubTypeWithProperty(typeof(Employee), "JobTitle")]
[KnownSubTypeWithProperty(typeof(Artist), "Skill")]
public class Person { }

Fallback subtype

[JsonSubTypeConverter(typeof(JsonSubtypes<IExpression>), "Type")]
[KnownSubType(typeof(ConstantExpression), "Constant")]
[FallBackSubType(typeof(UnknownExpression))]
public interface IExpression { }

Differences with the Newtonsoft.Json version

  • 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 via SerializeDiscriminatorProperty().
  • 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.Json emits properties most-derived-first, while the Newtonsoft version honored [JsonProperty(Order = N)]. There is no Order support in System.Text.Json.
  • Deeply nested graphs need MaxDepth about one level higher than with the Newtonsoft/plain serialization: the discriminator write path round-trips through a JsonDocument, which consumes one depth level. (A 64-level chain requires MaxDepth = 66 instead 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.
  • JsonNamingPolicy and PropertyNameCaseInsensitive are respected when matching the discriminator property, and JsonStringEnumConverter is respected when mapping discriminator values. Note that JsonStringEnumConverter (.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.Json exposes no property metadata for converter-owned types). [JsonPropertyName], [JsonIgnore] (including JsonIgnoreCondition), the naming policy and DefaultIgnoreCondition are honored; per-property [JsonConverter], [JsonInclude] fields, required members 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 the MaxDepth + 1 note 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 via JsonSubTypesTypeResolution). 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).

Choosing between the three engines

JsonSubTypes.Text.Json ships three engines that share the same configuration layer (the attributes and JsonSubtypesConverterBuilder), and a parity test battery keeps them aligned:

Feature / Capability Native STJ ([JsonDerivedType]) Resolver (BuildResolver()) Converter (Build()) Generator (JsonSubTypes.Aot)
Type discriminator mapping (string/int)
Enum / null discriminator values
Custom discriminator property name
Property presence matching (KnownSubTypeWithProperty)
Fallback subtype (FallBackSubType) base only
Discriminator written last
Naming policy / case-insensitive on the discriminator name ⚠️
Dotted / nested discriminator path ("nested.type")
Nested (multi-level) hierarchies ⚠️
Dynamic subtype registration at runtime ✅ (runtime map)
Custom type-name resolution hook ✅ (built-in) ✅ (hook)
Cross-assembly / plugin types outside the compilation ⚠️ (must be in the source-gen context)
Native AOT / Trimming support

The three niches:

  1. Converter (Build()) — the full-featured runtime engine. No generator setup, works with attributes directly, and is the only engine for runtime-by-nature scenarios (real cross-assembly plugins, arbitrary type names resolved by reflection). The right default for non-AOT applications.
  2. Resolver (BuildResolver()) — the thin native bridge. Supports only the subset the native contract model can express, but is the simplest and the fastest for that subset. A lightweight option for .NET 7+ apps that need string/int polymorphism without attributes on the domain and without a generator.
  3. Generator (JsonSubTypes.Aot) — a Roslyn source generator emitting compiled converters. Nearly feature-identical to the converter, and it is the Native AOT answer: the routing is compiled, so property presence, fallback, enums, nested hierarchies and dynamic registration all work without reflection in a trimmed/AOT binary. The generator reads the [JsonSubTypesAotConverter], [KnownSubType] and [FallBackSubType] attributes, so a consumer still references the JsonSubTypes.Text.Json package for those attributes (the generator itself is referenced as an analyzer).

Converter known scope & fallback path

To preserve full compatibility with advanced features while delegating object serialization to System.Text.Json, the converter isolates base-type serialization to a narrow path (when serializing the base type directly or reading an unregistered fallback type):

  • Subtypes (most cases): Full delegation to System.Text.Json. STJ attributes ([JsonIgnore], [JsonInclude], property [JsonConverter], [JsonConstructor], record types, naming policies) are fully supported natively.
  • Base-as-leaf & Fallback path: lightweight direct property mapping honoring [JsonIgnore], [JsonPropertyName], the naming policy and PropertyNameCaseInsensitive. Per-property [JsonConverter], [JsonInclude] fields, required members and parameterized constructors are not re-implemented on this path.
  • Parameterless constructor required for the base fallback type. Subtypes resolved via the discriminator support all STJ constructor features (primary constructors, record types).

Decision matrix

Use case Recommended
Native AOT / trimming, hierarchy known at compile time JsonSubTypes.Aot generator
Non-AOT, full feature set with minimal setup Converter (Build())
Non-AOT, string/int discriminators only, fastest and simplest Resolver (BuildResolver())
Discriminator by property presence (no discriminator field in the JSON) Converter or Generator
Open hierarchies / subtypes registered at runtime Converter, or Generator (RegisterDynamicSubtype)
Non string/int discriminator values (enums, null) Converter or Generator
Nested or dotted discriminator paths (e.g. "nested.property") Converter or Generator
Resolution by arbitrary .NET type name / cross-assembly plugins Converter (built-in), or Generator (CustomTypeNameResolver hook)
Migrating an existing JsonSubTypes/Newtonsoft code base Converter (same API)

Native AOT

The resolver and the converter rely on reflection and are therefore not compatible with trimming or Native AOT. The polymorphic metadata that the resolver configures must be declared at compile time for AOT: System.Text.Json freezes it at build time, and a source-generated JsonTypeInfo is read-only at runtime. Assigning PolymorphismOptions to a source-generated JsonTypeInfo throws InvalidOperationException on both .NET 8 and .NET 10.

For Native AOT, the JsonSubTypes.Aot generator compiles the routing into the converter (verified to run as a native binary with dotnet publish -r linux-x64 -p:PublishAot=true). The generator is referenced as an analyzer and reads its attributes ([JsonSubTypesAotConverter], [KnownSubType], …) from the JsonSubTypes.Text.Json package, so reference both JsonSubTypes.Aot and JsonSubTypes.Text.Json:

dotnet add package JsonSubTypes.Aot
dotnet add package JsonSubTypes.Text.Json

Alternatively, declare the hierarchy with [JsonDerivedType] on the base type and use a plain source-generated context:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Circle), "circle")]
[JsonDerivedType(typeof(Square), "square")]
public class Shape { }

[JsonSerializable(typeof(Shape))]
[JsonSerializable(typeof(Circle))]
[JsonSerializable(typeof(Square))]
public partial class ShapeJsonContext : JsonSerializerContext { }

var options = new JsonSerializerOptions { TypeInfoResolver = ShapeJsonContext.Default };
var json = JsonSerializer.Serialize<Shape>(new Circle { Radius = 2 }, options);
// {"$type":"circle","Radius":2}

💖 Support this project

If this project helped you save money or time or simply makes your life also easier, you can give me a cup of coffee =)

  • Support via PayPal
  • Bitcoin — You can send me bitcoins at this address: 33gxVjey6g4Beha26fSQZLFfWWndT1oY3F

License

FOSSA Status

About

Discriminated Json Subtypes Converter implementation for .NET

Topics

Resources

Code of conduct

Stars

434 stars

Watchers

5 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages