On the spectrum of “chill” languages, JavaScript is on the higher end of permissible syntax. The ability to patch together a disparate group of values and treat them as one collection can help developers “get stuff done”. C# and .NET are on the more strict side, forcing developers into adopting consistency through class hierarchies and interface usage. Both languages have their advantages.
A common approach that many developers take is to write APIs using .NET and produce JSON to be consumed by front end UIs. With the latest releases of ASP.NET, the default serialization has shifted from JSON.NET to System.Text.Json
. The ASP.NET team has changed the default serializer to make the framework more performant, but in doing so, has dropped some of the “magic” that makes JSON.NET “just work”.
In this post, we’ll look at how we can use System.Text.Json
to serialize a interface instances with their concrete type properties in tact.
The Problem With Interfaces
As .NET developers, we use interfaces to group “like” things into collections or operate on them with similar actions, ignoring extraneous features like additional properties or instance methods. Let’s look at an example interface.
public interface IThing
{
public string Name { get; set; }
}
The IThing
interface has a Name
property. Given its simplicity, many types could implement this interface. Take for example, classes One
and Two
.
public class One : IThing
{
public string Name { get; set; }
}
public class Two : IThing
{
public int Count { get; set; }
public string Name { get; set; }
}
The difference between these two classes is that Two
implements an additional property of Count
.
What happens when we want to use System.Text.Json
to serialize our instances of IThing
to JSON?
IThing one = new One {Name = "One"};
IThing two = new Two {Name = "Two", Count = 42};
var things = new[] {one, two};
Console.WriteLine(
JsonSerializer.Serialize(things)
);
Well, since things
is a collection of IThing
, we get the following result.
[{"Name":"One"},{"Name":"Two"}]
As we may have noticed, we don’t see the value of Count
anywhere in our results. That’s because System.Text.Json
uses the type of the parameter we pass into the Serialize
method. In this case, the type is IThing[]
.
Fixing the Serialization Issue
There are several ways to solve this issue. Let’s start with our result and then walk through the multiple solutions.
[{"Name":"One"},{"Count":42,"Name":"Two"}]
Notice, we can now see each type’s properties as assigned in our C# code.
Using Object Instead Of An Interface
Making our array of type object
fixes the issue.
static void Main(string[] args)
{
IThing one = new One {Name = "One"};
IThing two = new Two {Name = "Two", Count = 42};
object[] things = { one, two };
Console.WriteLine(
JsonSerializer.Serialize(things)
);
}
Why does this happen? If we step into the serialization method, we can see that the .NET team has built a unique use-case for objects.
private static void WriteCore<TValue>(Utf8JsonWriter writer, TValue value, Type inputType, JsonSerializerOptions options)
{
Debug.Assert(writer != null);
// We treat typeof(object) special and allow polymorphic behavior.
if (inputType == typeof(object) && value != null)
{
inputType = value!.GetType();
}
WriteStack state = default;
state.Initialize(inputType, options, supportContinuation: false);
JsonConverter jsonConverter = state.Current.JsonClassInfo!.PropertyInfoForClassInfo.ConverterBase;
bool success = WriteCore(jsonConverter, writer, value, options, ref state);
Debug.Assert(success);
}
The object
trick is one we can use when dealing with specialized response objects that we only mean to use in our web API layer.
public class Response
{
public IEnumerable<object> Results { get; set; }
}
Use a Converter and JsonSerializerOptions
The Serialize
method allows us to pass in an instance of JsonSeriliazerOptions
where we can set the Converters
collection to include a converter for IThing
. Note, we don’t implement read here, and should be implemented dependant on the API surface area.
IThing one = new One {Name = "One"};
IThing two = new Two {Name = "Two", Count = 42};
var things = new [] { one, two };
Console.WriteLine(
JsonSerializer.Serialize(things, new JsonSerializerOptions
{
Converters = { new ThingConverter() }
})
);
Our converter implements JsonConverter<T>
where T
is of type IThing
.
public class ThingConverter : JsonConverter<IThing>
{
public override IThing Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
throw new NotImplementedException();
}
public override void Write(
Utf8JsonWriter writer,
IThing value,
JsonSerializerOptions options)
{
switch (value)
{
case null:
JsonSerializer.Serialize(writer, (IThing) null, options);
break;
default:
{
var type = value.GetType();
JsonSerializer.Serialize(writer, value, type, options);
break;
}
}
}
}
Using this approach will allow us to define the conversion options once in our application and use the converter throughout our codebase.
Use JsonConverterAttribute, Kind Of…
The System.Text.Json
package comes with a JsonConverterAttribute
that we can use to decorate a class, enum, or property. **In our case, this doesn’t work, since we are dealing with an interface. **
Luckily there is an easy workaround to this, which I found on a GitHub issue.
[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)]
public class JsonInterfaceConverterAttribute : JsonConverterAttribute
{
public JsonInterfaceConverterAttribute(Type converterType)
: base(converterType)
{
}
}
After implementing the new attribute class, we can decorate our IThing
interface in combination with our converter.
[JsonInterfaceConverter(typeof(ThingConverter))]
public interface IThing
{
public string Name { get; set; }
}
Our code becomes simplified, and converter definitions are now closer to the interfaces they convert.
static void Main(string[] args)
{
IThing one = new One {Name = "One"};
IThing two = new Two {Name = "Two", Count = 42};
var things = new [] { one, two };
// The JsonSerializer will find the attribute
Console.WriteLine(
JsonSerializer.Serialize(things)
);
}
Conclusion
There are multiple ways to serialize instances and all their properties. The easiest is to avoid interfaces, but we lose the value of collections and the ability to group “like” things in our response. We can choose to use object
if the model we are serializer is the end of the line for our data processing. If using object
leaves a bad taste in our mouth, we can also look at using the converter functionality exposed by the System.Text.Json
library. When dealing with interfaces, we will have to implement a workaround.
I hope you found this post helpful and let me know if you have other ways of approaching this issue in the comments.