DEV Community

Cover image for Deserializing Polymorphic JSON in .NET Without Losing Type Safety
Pierre Bouillon
Pierre Bouillon

Posted on

Deserializing Polymorphic JSON in .NET Without Losing Type Safety

JSON handling is often the bread and butter of an application, as data transit in and out of our project. Unfortunately, we don't always get to choose how they are structured and this can often come with a few challenges when we have to deal with polymorphism.

It may happen that we receive data that comes in different shapes, with some properties overlapping. To tackle this problem, there are a few ways to approach it with more or less type safety.

In this article, we will consider a practical approach, from the most simple, to the most robust.

Our Custom Map of French Hidden Gems

Congrats on your new project! You've just been tasked with building the back-end for a new interactive map of iconic french locations. We will have to ingest data from an external source and pass it on our application.

Unfortunately, the external API is not well structured, and these locations come as a mixed collection of points, with both landmarks and shops in the same array:

[
  {
    "Name": "Bouillon Chartier",
    "Category": "Restaurant",
    "Latitude": 48.8759,
    "Longitude": 2.3587,
    "Description": "Simple french dishes in an authentic 1950s atmosphere.",
    "OpeningHours": "11:30 - 00:00",
    "HasParking": false
  },
  {
    "Name": "Mont Saint-Michel",
    "Period": "Gothic",
    "Latitude": 48.6361,
    "Longitude": -1.5115,
    "Description": "Medieval abbey and UNESCO World Heritage Site."
  },
  {
    "Name": "Grand Café Foy",
    "Category": "Café",
    "Latitude": 48.6930,
    "Longitude": 6.1827,
    "Description": "A french café at the heart of the Place Stanislas.",
    "OpeningHours": "07:30 - 02:00",
    "HasParking": false
  },
  {
    "Name": "Cathedral of Our Lady of Strasbourg",
    "Period": "Gothic",
    "Latitude": 48.5819,
    "Longitude": 7.7513,
    "Description": "Catholic cathedral among the finest examples of Rayonnant Gothic architecture."
  }
]
Enter fullscreen mode Exit fullscreen mode

What was looking like a simple task might prove to be a little harder than anticipated as every entry has some basic geographic coordinates, but different properties based on their type. To make it even worse, there is no clear discriminator such as Type: "Landmark" for instance.

Fortunately, that's no big deal for the .NET devs we are!

The God Object

Deserializing data might seem straightforward at first glance. If a property might not be present, we can simply tell C# that it might be null. This approach, often called a "God Object", crams all possible properties into a single class, regardless of whether they apply to every instance.

From this statement, we can infer the following Location record:

public record Location(
    string Name,
    double Latitude,
    double Longitude,
    string Description,
    // Shop properties
    ShopCategory? Category,
    string? OpeningHours,
    bool? HasParking,
    // Landmark properties
    HistoricalPeriod? Period,
    decimal? EntryFee
);
Enter fullscreen mode Exit fullscreen mode

Digesting the data is now child's play:

var json = "...";
var locations = JsonSerializer.Deserialize<List<Location>>(json);
Enter fullscreen mode Exit fullscreen mode

While this will indeed work, it has one major drawback which is that we never know what kind of location we are currently working with. This will result in plenty of null checks, or if through the code.

Moreover, we are clearly seeing that there are two groups here:

Object Properties Overlapping

We could later parse our location and map each entry to a typed object, but that does not sounds to be very efficient.

Surely .NET has a better way to handle this kind of situation.

Polymorphic Deserialization

What if instead of creating our model (our Location record) around what the deserializer can handle, we proceed the other way around and define it as our data is structured?

Shaping Our Domain

We just saw that we have two kind of data, with a single common ground, let's represent things that way.

First, we represent our shared properties:

public abstract record MapLocation(
    string Name,
    double Latitude,
    double Longitude,
    string Description
);
Enter fullscreen mode Exit fullscreen mode

Then, the first type, let's say the shop:

public record Shop(
    string Name,
    double Latitude,
    double Longitude,
    string Description,
    ShopCategory Category,
    string OpeningHours,
    bool HasParking
) : MapLocation(Name, Latitude, Longitude, Description);
Enter fullscreen mode Exit fullscreen mode

And finally, the second type we are dealing with, the landmarks:

public record Landmark(
    string Name,
    double Latitude,
    double Longitude,
    string Description,
    HistoricalPeriod HistoricalPeriod,
    decimal? EntryFee
) : MapLocation(Name, Latitude, Longitude, Description)
{
    public bool IsFreeEntry => EntryFee is null;
}
Enter fullscreen mode Exit fullscreen mode

Notice that we can already have some intelligence in the typing? A landmark might not have any entry fee and the model can clearly define that. That was not possible in a meaningful way with our god object where every landmark's property could be nullable.

This looks much closer to the domain we are working with, let's test this out!

var locations = JsonSerializer.Deserialize<List<MapLocation>>(json);
Enter fullscreen mode Exit fullscreen mode

Unfortunately, running the previous code results in the following error:

An unhandled exception of type 'System.NotSupportedException' occurred in System.Text.Json.dll: 'Deserialization of interface or abstract types is not supported.
Enter fullscreen mode Exit fullscreen mode

The serializer is not able to infer what kind of data it should deserialize it into, and removing the abstract won't solve the issue as we won't be able to access either Landmark or Shop property.

What we really need is a way to help the serializer identify what kind of data it is dealing with.

Introducing A Converter

System.Text.Json has exactly the kind of type we are looking for: JsonConverter<T>.

A JsonConverter is type that will help our serializer translates a given type to and from JSON, using the Read and Write method. In our example, we only want to help it reading the data, so we will be focusing on the Read part.

Here is the outline of our new converter:

public sealed class MapLocationJsonConverter : JsonConverter<MapLocation>
{
    public override MapLocation? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
        => throw new NotImplementedException();

    public override void Write(
        Utf8JsonWriter writer,
        MapLocation value,
        JsonSerializerOptions options)
        => throw new NotImplementedException();
}
Enter fullscreen mode Exit fullscreen mode

If our data had an explicit discriminator, such as $type, we could have used [JsonDerivedType(typeof(...), typeDiscriminator: "...")] and skip this whole part. What makes it tricky here is that the discriminator is implicit by being the presence or the absence of a property.

Focusing on the Read method, we need to find a value in the JSON that will tell us if the current entry is a Shop or a Landmark. There is not a single good answer here, and for the example we will consider that any entry with an HistoricalPeriod property is a Landmark, and a Shop otherwise:

public override MapLocation? Read(
    ref Utf8JsonReader reader,
    Type typeToConvert,
    JsonSerializerOptions options)
{
    using var jsonDoc = JsonDocument.ParseValue(ref reader);
    var root = jsonDoc.RootElement;

    var hasHistoricalPeriod = root.TryGetProperty("Period", out _);

    return hasHistoricalPeriod
        ? root.Deserialize<Landmark>(options)
        : root.Deserialize<Shop>(options);
}
Enter fullscreen mode Exit fullscreen mode

Note that JsonDocument is used here for simplicity, but for large datasets, consider streaming approaches to avoid memory overhead.

We now have to let the serializer know it's there, and try again:

var options = new JsonSerializerOptions
{
    Converters = { new MapLocationJsonConverter() },
};

var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);
Enter fullscreen mode Exit fullscreen mode

If we run it again, we can observe that the MapLocation list contains only concrete classes of the appropriate type, hooray!

Debugger's View

We can now consume our items with .NET taking care of strongly typing everything.

Wrapping Up

In this article, we tackled the challenge of deserializing polymorphic JSON when no explicit discriminator is available.

We began with a straightforward but somewhat messy approach with a God Object that sacrifices type safety for simplicity. While functional, this method leads to harder debugging and cluttering your code with null checks and reducing maintainability.

We then shifted to a more robust solution using inheritance and a custom JsonConverter. While this requires a bit more upfront effort, it helps ensuring we benefit from the type safety .NET has to offer.


Photo by David Clode on Unsplash

Top comments (0)