Introduction
Recently I’ve had to work with a data model in C# that the team I’m on doesn’t own. We are using System.Text.Json
for our Serialisation/Deserialisation needs. The Data model we are using has multiple classes with a object properties, and it’s difficult to work with.
Using System.Text.Json
we could customise the conversion for all properties where the type is object
, but that is too far-reaching and might cause an unwanted impact on other classes/objects in the heirarchy which you don’t want to touch.
In this post I’m going to show you how we can target a specific object property on an object and customise it’s de/serialisation - allowing us to properly work with that data model after.
A small problem
Currently System.Text.Json
will do it’s best to deserialise object
properties. However, it won’t always work the way we want. You’ll get a JsonElement in the object
property. Which will give you the type of value the Deserialiser has found (string, number etc.), the JsonElement
also contains a reference to the JsonDocument
which you can use to go and get the real value that you were expecting.
It’s entirely possible to do it this way, but you’ll be doing loads of post-processing of the model. Whereas, it’d be better to instruct System.Text.Json
what to do during deserialisation and then let that take care of everything for you.
A contrived example
The example below is contrived, but is useful to demonstrate the point.
Imagine we don’t own the Person
class in the example, it comes from a NuGet package that we consume.
We call an API to get some JSON and then try to deserialise that into our model.
Unfortunately, on the person class, the Age
property is a custom type (IntOrString
), and the API can return either an int
or string
represenatation of the Person’s age (you’d hope this doesn’t happen in real life).
We want to instruct System.Text.Json
how to serialise this object properly. Rather than get a JsonElement
and have to do more work (as mentioned above).
The solution
Here are the data models we don’t own, but have to use:
class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public IntOrString Age { get; set; }
}
class IntOrString
{
public object Value { get; set; } // You guessed it, this could be and int or a string
}
We’ll then need to create some common JsonSerializerOptions
- this is an object containing different customisations we want to apply to our deserialisation. There’s lots you can do in here, however, I’m just going to focus on the important bits we need to solve this problem.
As we only want this functionality to apply to this certain response from our API, we can choose to neglect these JsonSerializerOptions
for other calls, or create other JsonSerializerOptions
for different API calls and responses.
Before we look at deserialisation, we’ll create a static property for our options that looks like this:
private static readonly JsonSerializerOptions jsonSerializerOptionsForPropertyModel = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
{
Modifiers =
{
ApplyCustomConverterToObjectProperties
}
}
};
Here, we’ve told System.Text.Json
that we’ll be using it’s default TypeInfoResolver, but we’re going to provide a method where it’ll allow us to jump in and take care of some of the decisions/processing.
The method we’ve supplied looks like this:
/// <summary>
/// Applies the custom Json Converter <see cref="ObjectToInferredTypesConverter"/> to only specific properties on a containing type.
/// This is due to the API returning a response over JSON, where the response model's property type is <see cref="object"/>, with no Type Discriminator
/// information. In this circumstance, we (and System.Text.Json) don't know what the type should be, and it doesn't want to make a guess. So we need to single out this property and run our converter on it,
/// where we work through a number of types we're happy to deserialise and then give them a try.
/// </summary>
/// <param name="typeInfo">The Json Type Info</param>
private static void ApplyCustomConverterToObjectProperties(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind == JsonTypeInfoKind.Object && typeInfo.Type.FullName == typeof(IntOrString).FullName)
{
typeInfo.Properties.First(x => x.Name == nameof(IntOrString.Value)).CustomConverter = new ObjectToInferredTypesConverter();
}
}
Hopefully the above makes sense. What we’re doing is being given a JsonTypeInfo
, when we have a JsonTypeInfo
which is going to start work on the IntOrString
object and it’s .Value
property, we can jump in and add our custom converter for that property.
After we’ve registered the converter for this object, we just let System.Text.Json
get on with it’s job and we’ll have a properly deserialised model after it’s done.
The Custom ObjectToInferredTypesConverter
is simple and looks like this:
/// <summary>
/// A Custom <see cref="JsonConverter"/> which deserialises an object to a .NET value type
/// </summary>
internal class ObjectToInferredTypesConverter : JsonConverter<object>
{
public override object Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.Number when reader.TryGetInt64(out long l) => l,
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime,
JsonTokenType.String => reader.GetString()!,
_ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
};
}
public override void Write(
Utf8JsonWriter writer,
object objectToWrite,
JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
}
We’ve not customised writing here. We’ve just created a priority list of what to try, in what order, when reading.
The whole thing together
Below is an example which shows this all in action. We’ve got 2 different JSON strings which are fed into System.Text.Json
and will both result in data models, and the Person.Age.Value
property will both be of the correct .NET value type.
Feel free to paste this into a console application and put a breakpoint at the end to inspect the models.
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace CustomPropertyDeserialisationJson;
class Program
{
private static readonly JsonSerializerOptions jsonSerializerOptionsForPropertyModel = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
{
Modifiers =
{
ApplyCustomConverterToObjectProperties
}
}
};
static void Main(string[] args)
{
Console.WriteLine("Let's begin!");
// The Json where the age is an int
var personAgeNumJson = """
{
"FirstName": "John",
"LastName": "Doe",
"Age": {
"Value": 30
}
}
""";
// The Json where the age is a string
var personAgeStrJson = """
{
"FirstName": "John",
"LastName": "Doe",
"Age": {
"Value": "Thirty"
}
}
""";
var deserializedPersonAgeNum = JsonSerializer.Deserialize<Person>(personAgeNumJson, jsonSerializerOptionsForPropertyModel);
var deserializedPersonAgeStr = JsonSerializer.Deserialize<Person>(personAgeStrJson, jsonSerializerOptionsForPropertyModel);
Console.WriteLine(deserializedPersonAgeNum.Age.Value.GetType().Name); // Will print out "int"
Console.WriteLine(deserializedPersonAgeStr.Age.Value.GetType().Name); // Will print out "string"
Console.ReadLine();
}
/// <summary>
/// Applies the custom Json Converter <see cref="ObjectToInferredTypesConverter"/> to only specific properties on a containing type.
/// This is due to the API returning a response over JSON, where the response model's property type is <see cref="object"/>, with no Type Discriminator
/// information. In this circumstance, we (and System.Text.Json) don't know what the type should be, and it doesn't want to make a guess. So we need to single out this property and run our converter on it,
/// where we work through a number of types we're happy to deserialise and then give them a try.
/// </summary>
/// <param name="typeInfo">The Json Type Info</param>
private static void ApplyCustomConverterToObjectProperties(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind == JsonTypeInfoKind.Object && typeInfo.Type.FullName == typeof(IntOrString).FullName)
{
typeInfo.Properties.First(x => x.Name == nameof(IntOrString.Value)).CustomConverter = new ObjectToInferredTypesConverter();
}
}
}
class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public IntOrString Age { get; set; }
}
class IntOrString
{
public object Value { get; set; }
}
/// <summary>
/// A Custom <see cref="JsonConverter"/> which deserialises an object to a .NET value type
/// </summary>
internal class ObjectToInferredTypesConverter : JsonConverter<object>
{
public override object Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.Number when reader.TryGetInt64(out long l) => l,
JsonTokenType.Number => reader.GetDouble(),
JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime) => datetime,
JsonTokenType.String => reader.GetString()!,
_ => JsonDocument.ParseValue(ref reader).RootElement.Clone()
};
}
public override void Write(
Utf8JsonWriter writer,
object objectToWrite,
JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options);
}
Alternatives
If you owned the data model, the first point of call would be to use Type Discriminators, of which there’s a fantastic write up on Microsoft’s own website: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/polymorphism
Another alternative, would be to create your own data model, and inherit from the supplied data model. You’d add new properties on your model, which could deal with the object
properties on the base class and try to work around the vagueness in the given types. But for large object structures, this can get messy, will leave you duplicating & managing a lot of classes. It’s also not very elegant - and you’re at the mercy of the NuGet package - with version changes, you’ve likely got quite a bit of work to do to change your models to suit their new C# classes.
Closing
I hope this post has shown an alternative way to customise JSON de/serialisation. System.Text.Json
is a great library and has a lot of customisation points for developers, allowing us to jump in at certain parts of the process and make better decisions and take control at a granular level.
Thanks for reading!
comments powered by Disqus