Polymorphism with New JSON Serialization

September 25th 2020 .NET Serialization

With the new System.Text.Json built into .NET Core, JSON serialization can now be performed without the Json.NET library. However, there are differences between the two libraries once you go beyond the basics.

For example, support for serializing and deserializing polymorphic objects is limited in the new library. Let's say you need to handle JSON messages with different payloads depending on their type:

  • Type A includes a text field in the payload

    {
      "type": "A",
      "payload": {
        "text": "content"
      }
    }
    
  • Type B includes a number field in the payload instead:

    {
      "type": "B",
      "payload": {
        "number": 42
      }
    }
    

The different payloads can be modeled as multiple types derived from a common base type:

public class Payload { }

public class PayloadA : Payload
{
  public string Text { get; set; }
}

public class PayloadB : Payload
{
  public int Number { get; set; }
}

But how should the top-level message be modeled for the serialization and deserialization to work best?

Serialization

The intuitive choice would be to model the payload in the message using the base type:

public class MessageWithBaseType
{
  public string Type { get; set; }
  public Payload Payload { get; set; }
}

Unfortunately, this way only the properties from the base type will be serialized:

{
  "type": "A",
  "payload": {}
}

The only way to serialize all the properties of a derived type is to model it as object:

public class MessageWithObject
{
  public string Type { get; set; }
  public object Payload { get; set; }
}

Serializing this message type will result in the desired JSON:

{
  "type": "A",
  "payload": {
    "text": "content"
  }
}

Weak typing makes this class potentially dangerous. But if it's only used for serializing a request it might be worth it to avoid the complexity of a custom converter.

Deserialization

For deserializing a JSON message, none of the types above will include the instance of the correct derived class without a custom converter:

  • When the payload is typed as object, it will contain an instance of JsonElement after deserialization. The properties from the derived type can be accessed through it. But it's not strongly typed.
  • When the payload is typed as the base type, it will contain an instance of the base type without any properties of the derived type. Deserialization would fail if the base type was abstract.

The only way to deserialize JSON into the correct derived type would be to make the message generic with the payload type as its generic argument:

public class MessageGeneric<TPayload> : IMessageGeneric<TPayload>
  where TPayload: Payload
{
  public string Type { get; set; }
  public TPayload Payload { get; set; }
}

However, the correct payload type must now be specified when deserializing the message:

var value = JsonSerializer.Deserialize<MessageGeneric<PayloadA>>(json);

Unfortunately, you usually don't know the payload type without inspecting the type field first. If the performance impact of doing this is acceptable, this process can be wrapped in a helper deserialization method:

public static IMessageGeneric<Payload> Deserialize(string json)
{
  var jsonDocument = JsonDocument.Parse(json);
  var typeValue = jsonDocument.RootElement.GetProperty("type").GetString();

  switch (typeValue)
  {
    case "A":
      return Deserialize<MessageGeneric<PayloadA>>(json);
    case "B":
      return Deserialize<MessageGeneric<PayloadB>>(json);
    default:
      return Deserialize<MessageGeneric<Payload>>(json);
  }
}

Notice that the method returns an interface with the base type as a generic argument, instead of a class. This is necessary because MessageGeneric<PayloadA> can't be cast to MessageGeneric<Payload>. For casting to work, the target generic interface must be covariant:

public interface IMessageGeneric<out TPayload>
  where TPayload : Payload
{
  string Type { get; set; }
  TPayload Payload { get; }
}

Notice how the Payload property only has a getter. This is because a setter is not allowed for a covariant type argument.

You can get a sample project showcasing the serialization and deserialization behavior in each scenario from my GitHub repository.

Although, System.Text.Json doesn't fully support polymorphic serialization and deserialization, some of the limitations can be worked around. When that's not enough, there's still the option of writing a custom converter and taking full control over the serialization process.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License