Modern applications frequently deal with JSON structures that represent objects from an inheritance hierarchy. For instance, a list of Vehicle
objects might contain instances of Car
, Bike
, or Truck
. When deserializing such JSON, System.Text.Json
needs a way to determine the specific concrete type for each JSON object. This is known as polymorphic deserialization.
While System.Text.Json
has evolved to include built-in attributes for some polymorphic scenarios (notably in .NET 7 and later), there are many cases where these are insufficient or where developers need finer-grained control. This is where a custom JsonConverter<T>
becomes indispensable, particularly when using a “discriminator” field in the JSON to guide type resolution.
This article provides a deep dive into building custom JsonConverter
implementations for robust polymorphic deserialization in System.Text.Json
, focusing on the discriminator pattern. We’ll cover how to read the discriminator, map it to a concrete .NET type, and handle both serialization and deserialization effectively.
The Discriminator Pattern
The discriminator pattern involves including a special field in your JSON objects whose value indicates the actual type of the object. For example, if you have a base class Event
and derived classes LoginEvent
and LogoutEvent
, your JSON might look like this:
|
|
Here, eventType
is the discriminator. Its value ("Login"
or "Logout"
) tells the deserializer which specific C# class (LoginEvent
or LogoutEvent
) to instantiate. The name of the discriminator field (e.g., eventType
, $type
, kind
) is a design choice.
Building a Custom JsonConverter<T>
for Polymorphism
Let’s illustrate by building a converter for a simple Vehicle
hierarchy.
1. Define the Class Hierarchy
First, define the base class and derived classes.
|
|
Our goal is to deserialize JSON into Car
or Bike
objects when the target type is Vehicle
.
2. Create the Custom JsonConverter
We’ll create a VehicleConverter
that inherits from JsonConverter<Vehicle>
.
|
|
The CanConvert
method simply checks if the type being converted is Vehicle
or a type derived from it.
3. Implementing the Read
Method (Deserialization)
The Read
method is the heart of polymorphic deserialization. Its responsibilities are:
- Safely “peek” at the discriminator property in the JSON stream without fully consuming the reader.
- Determine the concrete .NET type based on the discriminator’s value.
- Deserialize the JSON object into an instance of that concrete type.
A robust way to peek at the discriminator is to parse the current JSON object into a JsonDocument
or JsonNode
. This allows inspection without advancing the original Utf8JsonReader
in a way that prevents subsequent full deserialization of the object. We’ll use JsonDocument
.
|
|
Explanation of the Read
method:
- Parse to
JsonDocument
:JsonDocument.ParseValue(ref reader)
parses the current JSON value (which should be an object) into aJsonDocument
. This effectively reads the entire object from thereader
. - Get Discriminator: We access the root
JsonElement
and useTryGetProperty
to find ourDiscriminatorProperty
(e.g., “vehicleType”). - Error Handling: If the discriminator is missing or not a string, a
JsonException
is thrown. - Map to Type: A
switch
statement maps the discriminator string value (e.g., “Car”, “Bike”) to the corresponding C#Type
(typeof(Car)
,typeof(Bike)
). - Deserialize to Concrete Type:
JsonSerializer.Deserialize(root.GetRawText(), actualType, options)
is called.root.GetRawText()
provides the JSON string for the current object.System.Text.Json
will then use its default internal converters (or other registered converters specific toCar
orBike
if any) to deserialize into theactualType
.
4. Implementing the Write
Method (Serialization)
When serializing a Vehicle
object (which could be a Car
or Bike
instance), we need to ensure the discriminator property is included in the JSON output.
A common and robust approach for the Write
method, especially when you don’t want to manually handle all properties of all derived types, is to:
- Serialize the
value
(which is the concrete derived type instance likeCar
orBike
) into aSystem.Text.Json.Nodes.JsonObject
. - Add the discriminator property to this
JsonObject
. - Write the modified
JsonObject
to theUtf8JsonWriter
.
This requires the System.Text.Json.Nodes
namespace.
|
|
Explanation of the Write
method:
- Temporary Options: We create
tempOptions
by copying the originaloptions
and removing theVehicleConverter
from it. This is crucial to prevent infinite recursion whenJsonSerializer.SerializeToNode
is called. We wantSystem.Text.Json
to use its default serialization logic for the actualCar
orBike
type. - Serialize to
JsonObject
:JsonSerializer.SerializeToNode(value, value.GetType(), tempOptions)
serializes the concrete object (e.g.,Car
instance) into aJsonObject
.value.GetType()
ensures we serialize all properties of the derived type. - Determine Discriminator: We check the actual type of
value
usingis
patterns and set thediscriminatorValue
. - Add Discriminator:
jsonObject[DiscriminatorProperty] = JsonValue.Create(discriminatorValue)
adds the discriminator field (e.g.,"vehicleType": "Car"
) to theJsonObject
. - Write
JsonObject
:jsonObject.WriteTo(writer, options)
writes the complete JSON structure, including the added discriminator, to the output stream.
5. Registering and Using the Custom Converter
To use the VehicleConverter
, add an instance of it to JsonSerializerOptions.Converters
.
|
|
Expected Output:
|
|
Advantages of a Custom JsonConverter
- Full Control: You have complete programmatic control over the serialization and deserialization logic.
- Complex Mapping: Handle sophisticated discriminator logic, such as mappings derived from multiple fields, external configurations, or different discriminator names per type.
- Legacy Systems: Integrate with systems that use non-standard discriminator patterns.
- Compatibility: Works across all .NET versions that support
System.Text.Json
(unlike some newer built-in attributes).
Comparison with .NET 7+ Built-in Polymorphism
.NET 7 introduced built-in attributes that simplify common polymorphic scenarios:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "customDiscriminator")]
applied to the base class.[JsonDerivedType(derivedType: typeof(DerivedType), typeDiscriminator: "discriminatorValue")]
applied to the base class, one for each derived type.
Microsoft Docs: Serialize properties of derived classes
When to use built-in attributes:
- For straightforward discriminator logic where a single property name is used.
- When targeting .NET 7 or later and the built-in functionality meets requirements.
- For potentially better performance as they are part of the core library.
When a custom JsonConverter
is still preferred:
- Pre-.NET 7: If your application targets an older .NET version.
- Complex Discriminator Logic: If the discriminator isn’t a simple string value, or if its value needs transformation, or if it’s derived from multiple JSON properties.
- Varying Discriminator Names: If different derived types use different property names as discriminators.
- External Type Mapping: If the mapping between discriminator values and types is stored externally (e.g., in a configuration file or database).
- Side Effects or Custom Initialization: If deserialization requires custom initialization logic beyond what constructors can provide, based on the JSON content.
Common Pitfalls and Best Practices
- Reader Consumption: Incorrectly reading from the
Utf8JsonReader
in theRead
method can consume tokens needed for subsequent deserialization. UsingJsonDocument.ParseValue(ref reader)
orJsonSerializer.Deserialize<JsonElement>(ref reader, options)
helps manage this by parsing the current object into an inspectable DOM. - Recursive Converter Calls: In the
Write
method, if you callJsonSerializer.Serialize
with the sameJsonSerializerOptions
(which includes the custom converter itself) for the same base type, you can cause a stack overflow. The solution, as shown, is to serialize toJsonObject
using options without the current converter, or to serialize the concrete derived type where the default serializers can take over. - Error Handling: Implement robust error handling for missing or unknown discriminator values. Throwing a
JsonException
is a common practice. - Discriminator Naming: Choose a discriminator property name that is unlikely to clash with actual data properties (e.g.,
"$type"
,_discriminator
,objectKind
). - Performance: While
JsonDocument
orJsonObject
offer convenience, they do involve an intermediate allocation. For extreme performance-critical paths, manually working withUtf8JsonReader
andUtf8JsonWriter
is possible but significantly more complex. The .NET 7+ built-in attributes are generally well-optimized.
Conclusion
Custom JsonConverter
implementations in System.Text.Json
provide a powerful and flexible mechanism for handling polymorphic deserialization and serialization using the discriminator pattern. While .NET 7+ offers convenient built-in attributes for simpler cases, custom converters remain essential for advanced scenarios, backward compatibility, and situations requiring fine-grained control over the JSON mapping process. By understanding how to properly read and write discriminator information, developers can build robust and adaptable .NET applications that seamlessly integrate with diverse JSON structures.