Deserializing Polymorphic JSON in .NET Without Losing Type Safety
Source: Dev.to
The Scenario
Congrats on your new project! You’ve just been tasked with building the back‑end for an interactive map of iconic French locations. We have to ingest data from an external source and expose it through our API.
Unfortunately the external API is not well‑structured: the 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."
}
]
Every entry has basic geographic coordinates, but the type‑specific properties differ. To make matters worse, there is no clear discriminator such as Type: "Landmark".
The “God Object” Approach
A quick‑and‑dirty solution is to create a single class that contains all possible properties, marking the ones that may be missing as nullable. This is often called a God Object.
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);
Deserialization becomes trivial:
var json = "...";
var locations = JsonSerializer.Deserialize<List<Location>>(json);
Drawbacks
- We never know what kind of location we are dealing with → many
nullchecks. - The model does not reflect the domain (shops vs. landmarks).
- Adding new location types quickly becomes unmanageable.
A Domain‑Driven Model
Instead of shaping the model to satisfy the deserializer, let’s model the data as it is.
Shared base type
public abstract record MapLocation(
string Name,
double Latitude,
double Longitude,
string Description);
Concrete derived types
public record Shop(
string Name,
double Latitude,
double Longitude,
string Description,
ShopCategory Category,
string OpeningHours,
bool HasParking) : MapLocation(Name, Latitude, Longitude, Description);
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;
}
Now the type system tells us whether a location is a Shop or a Landmark, and we can encode domain rules (e.g., a landmark may have a free entry).
Attempting to deserialize directly:
var locations = JsonSerializer.Deserialize<List<MapLocation>>(json);
…throws:
System.NotSupportedException: Deserialization of interface or abstract types is not supported.
The serializer cannot infer which concrete type to instantiate.
Guiding the Serializer with a Custom Converter
System.Text.Json provides JsonConverter to control how a type is read and written. We only need to implement reading for our polymorphic hierarchy.
Skeleton of the converter
public sealed class MapLocationJsonConverter : JsonConverter<MapLocation>
{
public override MapLocation? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
// 1️⃣ Load the JSON object into a JsonDocument for inspection
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
// 2️⃣ Decide which concrete type to deserialize into
// – If the JSON contains a "Category" property → Shop
// – If it contains a "Period" property → Landmark
// (Add more rules as needed)
if (root.TryGetProperty("Category", out _))
{
// Deserialize as Shop
return JsonSerializer.Deserialize<Shop>(root.GetRawText(), options);
}
if (root.TryGetProperty("Period", out _))
{
// Deserialize as Landmark
return JsonSerializer.Deserialize<Landmark>(root.GetRawText(), options);
}
// Fallback – could throw or return null
throw new JsonException("Unable to determine MapLocation subtype.");
}
// We are not interested in writing in this example, but we must implement it.
public override void Write(
Utf8JsonWriter writer,
MapLocation value,
JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, (object)value, options);
}
Registering the converter
var options = new JsonSerializerOptions
{
Converters = { new MapLocationJsonConverter() },
PropertyNameCaseInsensitive = true
};
var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);
Now locations contains a mixture of Shop and Landmark objects, each with the correct strongly‑typed properties.
Recap
| Approach | Pros | Cons |
|---|---|---|
God Object (Location) | Simple, one‑liner deserialization | Lots of nulls, no type safety, hard to extend |
| Domain model + custom converter | Strong typing, reflects real domain, extensible | Slightly more code, need to maintain converter logic |
When dealing with polymorphic JSON without a discriminator, a custom JsonConverter that inspects the incoming payload and selects the appropriate concrete type is the idiomatic .NET solution.
Happy coding! 🚀
=> throw new NotImplementedException();
public override void Write(
Utf8JsonWriter writer,
MapLocation value,
JsonSerializerOptions options) =>
throw new NotImplementedException();
}
If our data had an explicit discriminator, such as $type, we could have used
[JsonDerivedType(typeof(...), typeDiscriminator: "...")]
and skipped this whole part. What makes it tricky here is that the discriminator is implicit—it is the presence (or absence) of a property.
The Read method
We need to find a value in the JSON that tells us whether the current entry is a Shop or a Landmark. There isn’t a single “right” answer; for this example we’ll treat any entry that contains a HistoricalPeriod property as a Landmark, and everything else as a Shop.
public override MapLocation? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
// Parse the JSON fragment once so we can inspect it.
using var jsonDoc = JsonDocument.ParseValue(ref reader);
var root = jsonDoc.RootElement;
// Does the object contain a "Period" property?
var hasHistoricalPeriod = root.TryGetProperty("Period", out _);
// Deserialize to the appropriate concrete type.
return hasHistoricalPeriod
? root.Deserialize<Landmark>(options)
: root.Deserialize<Shop>(options);
}
Note:
JsonDocumentis used here for simplicity. For large data sets you should consider a streaming approach to avoid the memory overhead of loading the whole fragment into a DOM.
Register the converter (again)
var options = new JsonSerializerOptions
{
Converters = { new MapLocationJsonConverter() },
};
var locations = JsonSerializer.Deserialize<List<MapLocation>>(json, options);
Running the code again shows that the List now contains only concrete instances of the appropriate type—hooray!
What we achieved
- We can now consume the items with .NET handling the strong typing for us.
- The polymorphic deserialization works even without an explicit discriminator.
Recap
In this article we tackled the challenge of deserializing polymorphic JSON when no explicit discriminator is available.
- God‑object approach – a quick but messy solution that sacrifices type safety, leads to harder debugging, and clutters the code with null checks.
- Inheritance + custom
JsonConverter– a more robust solution that requires a bit more upfront effort but gives us the full benefit of .NET’s type safety and maintainability.
Photo by David Clode on Unsplash