Polymorphism is a powerful concept that allows us to treat objects of different types in a uniform way. However, when it comes to serializing and deserializing these objects to and from JSON, things can get a bit tricky. This is where polymorphic serialization comes into play.
Let's say you're creating a mobile app and that app will display a stream of data with different properties ordered by a timestamp. For example, a social network might show photos, videos and text posts. Each of these types will share some properties (e.g., Timestamp, Id, UserId), but there will be some properties unique to those types (e.g., VideoUrl, PhotoUrl, TextContent). Suppose you wanted to offer this data through an API endpoint such as /stream?lastTimestamp?=DATETIME&count=10, which will retrieve the 10 most recent things to display since lastTimestamp. It seems simple, but you'll soon realize when using System.Text.Json that serializing these types isn't supported out of the box.
What is polymorphic serialization?
Polymorphic serialization is the process of serializing and deserializing objects of different types that share a common base type. This allows us to preserve the specific type information of each object, which is crucial when we want to deserialize the objects back into their original types.
Polymorphic serialization with System.Text.Json
In .NET, the System.Text.Json library is used for JSON serialization. However, it doesn't support polymorphic serialization out of the box. To achieve this, we need to create a custom JsonConverter.
Let's consider a real-world use case where we have a mobile app that displays a stream of data of various types. We have a base class, StreamItemDTO, and three subclasses: EventStreamItemDTO, NewsStreamItemDTO, and PhotoStreamItemDTO. Each subclass represents a different type of item in the stream.
public class StreamItemDTO
{
public required long Id { get; set; }
public required string Type { get; set; }
public long? MediaId { get; set; }
public required string Title { get; set; }
public required string Description { get; set; }
public required DateTime Timestamp { get; set; }
}
public class EventStreamItemDTO : StreamItemDTO
{
public required long EventTypeId { get; set; }
public required string EventType { get; set; }
public required DateTime EventDate { get; set; }
public string MediaUrl { get; set; } = string.Empty;
public string MediaPreviewUrl { get; set; } = string.Empty;
public string MediaThumbnailUrl { get; set; } = string.Empty;
}
public class NewsStreamItemDTO : StreamItemDTO
{
public string MediaUrl { get; set; } = string.Empty;
public string MediaPreviewUrl { get; set; } = string.Empty;
public string MediaThumbnailUrl { get; set; } = string.Empty;
}
public class PhotoStreamItemDTO : StreamItemDTO
{
public required int Likes { get; set; }
public required int Comments { get; set; }
public required string MediaUrl { get; set; }
public required string MediaPreviewUrl { get; set; }
public required string MediaThumbnailUrl { get; set; }
}
We want to return a combined response of these different types of data from our API. To do this, we need to create a custom JsonConverter for StreamItemDTO and its subclasses:
public class StreamItemDTOConverter : JsonConverter<StreamItemDTO>
{
public override StreamItemDTO Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var jsonObject = JsonDocument.ParseValue(ref reader).RootElement;
if (jsonObject.TryGetProperty("Type", out var typeProperty))
{
var type = typeProperty.GetString();
if (type == nameof(EventStreamItemDTO))
{
return JsonSerializer.Deserialize<EventStreamItemDTO>(jsonObject.GetRawText(), options)!;
}
else if (type == nameof(NewsStreamItemDTO))
{
return JsonSerializer.Deserialize<NewsStreamItemDTO>(jsonObject.GetRawText(), options)!;
}
else if (type == nameof(PhotoStreamItemDTO))
{
return JsonSerializer.Deserialize<PhotoStreamItemDTO>(jsonObject.GetRawText(), options)!;
}
}
throw new JsonException();
}
public override void Write(Utf8JsonWriter writer, StreamItemDTO value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, (dynamic)value, options);
}
}
In the Write method, we use dynamic dispatch to call the correct Serialize method based on the runtime type of the StreamItemDTO object. This ensures that the serialized JSON includes all the properties of the subclass.
The Read method is used to customize the deserialization process. However, if you're only serializing StreamItemDTO objects and not deserializing them, the Read method won't be used.
Changes to startup in Program.cs
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new StreamItemDTOConverter());
});
This will add the StreamItemDTOConverter to the list of converters used by the JSON serializer. Now, whenever a StreamItemDTO is serialized or deserialized, our custom converter will be used.
Wrapping up
Polymorphic serialization is a powerful technique that allows us to handle objects of different types in a uniform way. While it's not supported out of the box in System.Text.Json, we can achieve it by creating a custom JsonConverter. This enables us to handle complex use cases like returning a combined response of multiple types of data from our API.