adllm Insights logo adllm Insights logo

Mastering Polymorphic Deserialization in System.Text.Json with Custom JsonConverters and Discriminators

Published on by The adllm Team. Last modified: . Tags: System.Text.Json JsonConverter Polymorphism Deserialization Discriminator C# .NET JSON

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[
  {
    "eventType": "Login",
    "timestamp": "2023-10-26T10:00:00Z",
    "userId": "user123"
  },
  {
    "eventType": "Logout",
    "timestamp": "2023-10-26T10:05:00Z",
    "userId": "user123",
    "reason": "SessionTimeout"
  }
]

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Base abstract class for all vehicles
public abstract class Vehicle
{
    public required string Make { get; set; }
    public required string Model { get; set; }
}

// Derived class for Cars
public class Car : Vehicle
{
    public int NumberOfDoors { get; set; }
}

// Derived class for Bikes
public class Bike : Vehicle
{
    public bool HasBasket { get; set; }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Nodes; // Required for JsonObject in Write method

public class VehicleConverter : JsonConverter<Vehicle>
{
    // The name of the discriminator property in the JSON.
    // Make this configurable if needed.
    private const string DiscriminatorProperty = "vehicleType";

    public override bool CanConvert(Type typeToConvert)
    {
        // This converter can handle the Vehicle base class.
        return typeof(Vehicle).IsAssignableFrom(typeToConvert);
    }

    // Read and Write methods will be implemented next.
    public override Vehicle? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        // Implementation in the next section
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        Vehicle value,
        JsonSerializerOptions options)
    {
        // Implementation in a later section
        throw new NotImplementedException();
    }
}

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:

  1. Safely “peek” at the discriminator property in the JSON stream without fully consuming the reader.
  2. Determine the concrete .NET type based on the discriminator’s value.
  3. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    public override Vehicle? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        // It's important to clone the reader if you need to explore the JSON
        // and then pass the original reader to deserialize the chosen type.
        // However, JsonDocument.ParseValue consumes the reader for the current
        // token, so we use it to read the object and then deserialize from it.
        JsonDocument jsonDocument = JsonDocument.ParseValue(ref reader);
        JsonElement root = jsonDocument.RootElement;

        if (!root.TryGetProperty(DiscriminatorProperty, out JsonElement discriminatorElement) ||
            discriminatorElement.ValueKind != JsonValueKind.String)
        {
            throw new JsonException(
                $"Missing or invalid discriminator property " +
                $"'{DiscriminatorProperty}'."
            );
        }

        string discriminatorValue = discriminatorElement.GetString()!;
        Type actualType;

        switch (discriminatorValue)
        {
            case "Car":
                actualType = typeof(Car);
                break;
            case "Bike":
                actualType = typeof(Bike);
                break;
            default:
                throw new JsonException(
                    $"Unknown discriminator value: {discriminatorValue}"
                );
        }

        // Deserialize the JObject to the determined actual type.
        // We use root.GetRawText() to get the original JSON for this object
        // and deserialize it.
        // Note: Using options without this converter for the inner call
        // can be tricky. Here, deserializing the specific type should work.
        // If options contained this converter and we passed Vehicle,
        // it would recurse. By passing the concrete type, it's fine.
        var vehicle = (Vehicle?)JsonSerializer.Deserialize(
            root.GetRawText(),
            actualType,
            options // Pass original options, STJ handles it for concrete type
        );

        return vehicle;
    }

Explanation of the Read method:

  1. Parse to JsonDocument: JsonDocument.ParseValue(ref reader) parses the current JSON value (which should be an object) into a JsonDocument. This effectively reads the entire object from the reader.
  2. Get Discriminator: We access the root JsonElement and use TryGetProperty to find our DiscriminatorProperty (e.g., “vehicleType”).
  3. Error Handling: If the discriminator is missing or not a string, a JsonException is thrown.
  4. Map to Type: A switch statement maps the discriminator string value (e.g., “Car”, “Bike”) to the corresponding C# Type (typeof(Car), typeof(Bike)).
  5. 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 to Car or Bike if any) to deserialize into the actualType.

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:

  1. Serialize the value (which is the concrete derived type instance like Car or Bike) into a System.Text.Json.Nodes.JsonObject.
  2. Add the discriminator property to this JsonObject.
  3. Write the modified JsonObject to the Utf8JsonWriter.

This requires the System.Text.Json.Nodes namespace.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
    public override void Write(
        Utf8JsonWriter writer,
        Vehicle value,
        JsonSerializerOptions options)
    {
        // Remove this converter from options for the recursive call,
        // or ensure the specific type is handled by default serializers.
        // A common way is to create options without this specific converter.
        var tempOptions = new JsonSerializerOptions(options);
        tempOptions.Converters.Remove(
            tempOptions.Converters.FirstOrDefault(c => c is VehicleConverter)
        );

        // Serialize the value to a JsonObject
        JsonObject jsonObject = JsonSerializer.SerializeToNode(value, value.GetType(), tempOptions)!.AsObject();

        // Add the discriminator property
        string discriminatorValue;
        if (value is Car)
        {
            discriminatorValue = "Car";
        }
        else if (value is Bike)
        {
            discriminatorValue = "Bike";
        }
        else
        {
            throw new JsonException(
                $"Unknown type for Vehicle serialization: {value.GetType()}"
            );
        }
        jsonObject[DiscriminatorProperty] = JsonValue.Create(discriminatorValue);

        // Write the modified JsonObject to the writer
        jsonObject.WriteTo(writer, options);
    }

Explanation of the Write method:

  1. Temporary Options: We create tempOptions by copying the original options and removing the VehicleConverter from it. This is crucial to prevent infinite recursion when JsonSerializer.SerializeToNode is called. We want System.Text.Json to use its default serialization logic for the actual Car or Bike type.
  2. Serialize to JsonObject: JsonSerializer.SerializeToNode(value, value.GetType(), tempOptions) serializes the concrete object (e.g., Car instance) into a JsonObject. value.GetType() ensures we serialize all properties of the derived type.
  3. Determine Discriminator: We check the actual type of value using is patterns and set the discriminatorValue.
  4. Add Discriminator: jsonObject[DiscriminatorProperty] = JsonValue.Create(discriminatorValue) adds the discriminator field (e.g., "vehicleType": "Car") to the JsonObject.
  5. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Prepare JsonSerializerOptions
var options = new JsonSerializerOptions
{
    WriteIndented = true, // For pretty printing
    Converters = { new VehicleConverter() }
};

// --- Deserialization Example ---
string jsonInput = """
[
  {
    "vehicleType": "Car",
    "make": "Toyota",
    "model": "Camry",
    "numberOfDoors": 4
  },
  {
    "vehicleType": "Bike",
    "make": "Schwinn",
    "model": "Roadster",
    "hasBasket": true
  }
]
""";

List<Vehicle>? vehicles = JsonSerializer.Deserialize<List<Vehicle>>(jsonInput, options);

if (vehicles != null)
{
    foreach (var vehicle in vehicles)
    {
        Console.WriteLine($"Type: {vehicle.GetType().Name}, Make: {vehicle.Make}");
        if (vehicle is Car car)
        {
            Console.WriteLine($"  Doors: {car.NumberOfDoors}");
        }
        else if (vehicle is Bike bike)
        {
            Console.WriteLine($"  Has Basket: {bike.HasBasket}");
        }
    }
}

// --- Serialization Example ---
var myCar = new Car { 
    Make = "Honda", Model = "Civic", NumberOfDoors = 2 
};
var myBike = new Bike { 
    Make = "Trek", Model = "FX2", HasBasket = false 
};

List<Vehicle> newVehicles = new List<Vehicle> { myCar, myBike };
string jsonOutput = JsonSerializer.Serialize(newVehicles, options);
Console.WriteLine("\nSerialized Output:");
Console.WriteLine(jsonOutput);

Expected Output:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Type: Car, Make: Toyota
  Doors: 4
Type: Bike, Make: Schwinn
  Has Basket: true

Serialized Output:
[
  {
    "vehicleType": "Car",
    "make": "Honda",
    "model": "Civic",
    "numberOfDoors": 2
  },
  {
    "vehicleType": "Bike",
    "make": "Trek",
    "model": "FX2",
    "hasBasket": false
  }
]

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 the Read method can consume tokens needed for subsequent deserialization. Using JsonDocument.ParseValue(ref reader) or JsonSerializer.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 call JsonSerializer.Serialize with the same JsonSerializerOptions (which includes the custom converter itself) for the same base type, you can cause a stack overflow. The solution, as shown, is to serialize to JsonObject 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 or JsonObject offer convenience, they do involve an intermediate allocation. For extreme performance-critical paths, manually working with Utf8JsonReader and Utf8JsonWriter 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.