System.Text.Json – Use JsonConverterFactory to serialize multiple types the same way

Let’s say you want to serialize the four datetime types – DateTime, DateTime?, DateTimeOffset, and DateTimeOffset? – in the same way. You want to serialize them to use the US date style (ex: 7/14/2021).

There are two main ways to accomplish this:

  • Create a custom converter for each type.
  • Create a custom converter factory + generic custom converter.

Here’s how to do the custom converter factory approach to solve this problem:

using System.Text.Json; using System.Text.Json.Serialization; public class DateTimeConverterFactory : JsonConverterFactory { public override bool CanConvert(Type typeToConvert) { return typeToConvert == typeof(DateTime) || typeToConvert == typeof(DateTime?) || typeToConvert == typeof(DateTimeOffset) || typeToConvert == typeof(DateTimeOffset?); } public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { //You may be tempted to cache these converter objects. //Don't. JsonSerializer caches them already. if (typeToConvert == typeof(DateTime)) { return new DateTimeConverter<DateTime>(); } else if (typeToConvert == typeof(DateTime?)) { return new DateTimeConverter<DateTime?>(); } else if (typeToConvert == typeof(DateTimeOffset)) { return new DateTimeConverter<DateTimeOffset>(); } else if (typeToConvert == typeof(DateTimeOffset?)) { return new DateTimeConverter<DateTimeOffset?>(); } throw new NotSupportedException("CreateConverter got called on a type that this converter factory doesn't support"); } private class DateTimeConverter<T> : JsonConverter<T> { public override void Write(Utf8JsonWriter writer, T date, JsonSerializerOptions options) { writer.WriteStringValue((date as dynamic).ToString("MM/dd/yyyy")); //US date style } public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { //Don't need to implement this unless you're using this to deserialize too throw new NotImplementedException(); } } }
Code language: C# (cs)

To use the custom converter factory, add it to JsonSerializerOptions.Converters and pass the options in while serializing, like this:

var dates = new Dates() { DateTime = DateTime.Now, DateTimeNullable = null, DateTimeOffset = DateTimeOffset.Now, DateTimeOffsetNullable = DateTimeOffset.Now }; var options = new JsonSerializerOptions() { WriteIndented = true }; options.Converters.Add(new DateTimeConverterFactory()); var json = JsonSerializer.Serialize(dates, options); Console.WriteLine(json);
Code language: C# (cs)

This outputs the following:

{ "DateTime": "07/14/2021", "DateTimeNullable": null, "DateTimeOffset": "07/14/2021", "DateTimeOffsetNullable": "07/14/2021" }
Code language: JSON / JSON with Comments (json)

The main benefit of this approach is that everything is contained in a single class. The complexity is isolated. The code using the serializer only needs to know to pass in a custom converter factory object.

In this article, I’ll go into more details about this custom converter factory approach and how it compares with creating multiple converters.

Is the JsonConverterFactory approach better than creating multiple custom converters?

The alternative way to serialize multiple types the same way is to create multiple custom converters and duplicate the serialization logic, like this:

public class DateTimeConverter : JsonConverter<DateTime> { public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotImplementedException(); } public override void Write(Utf8JsonWriter writer, DateTime date, JsonSerializerOptions options) { writer.WriteStringValue(date.ToString("MM/dd/yyyy")); } } public class DateTimeNullableConverter : JsonConverter<DateTime?> { public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotImplementedException(); } public override void Write(Utf8JsonWriter writer, DateTime? date, JsonSerializerOptions options) { writer.WriteStringValue(date.GetValueOrDefault().ToString("MM/dd/yyyy")); } } public class DateTimeOffsetConverter : JsonConverter<DateTimeOffset> { public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotImplementedException(); } public override void Write(Utf8JsonWriter writer, DateTimeOffset date, JsonSerializerOptions options) { writer.WriteStringValue(date.ToString("MM/dd/yyyy")); } } public class DateTimeOffsetNullableConverter : JsonConverter<DateTimeOffset?> { public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { throw new NotImplementedException(); } public override void Write(Utf8JsonWriter writer, DateTimeOffset? date, JsonSerializerOptions options) { writer.WriteStringValue(date.GetValueOrDefault().ToString("MM/dd/yyyy")); } }
Code language: C# (cs)

Duplicate the logic? Yes.

The only thing you can de-dupe is the “MM/dd/yyyy” format string. To de-dupe the logic, you’d have to resort to dynamic typing. The custom converter factory approach uses dynamic typing, but it does so in a controlled way that is safe. If you were going to do that, you’d be better off creating a generic custom converter that is public (I’ll explain in the next section why it’s better to keep this private).

Furthermore, if you were to create multiple custom converters, you’d have to pass them all in during serialization, like this:

var dates = new Dates() { DateTime = DateTime.Now, DateTimeNullable = null, DateTimeOffset = DateTimeOffset.Now, DateTimeOffsetNullable = DateTimeOffset.Now }; var options = new JsonSerializerOptions() { WriteIndented = true }; options.Converters.Add(new DateTimeConverter()); options.Converters.Add(new DateTimeNullableConverter()); options.Converters.Add(new DateTimeOffsetConverter()); options.Converters.Add(new DateTimeOffsetNullableConverter()); var json = JsonSerializer.Serialize(dates, options); Console.WriteLine(json);
Code language: C# (cs)

This creates a burden for the client code. You must remember to pass in all of these classes. With the custom converter factory approach, you only need to pass in a single converter object.

The custom converter factory approach is simpler. It isolates the complexity. In this multiple custom converter approach, you have to deal with multiple classes, logic duplication, and the client code has to do more work (passing multiple converters in instead of a single converter).

Why make the generic custom converter a private class?

The reason to make the generic custom converter class private is we can make assumptions about the types were dealing with, allowing us to use dynamic typing.

Take a look at the serialization logic. It’s using dynamic typing to call .ToString(format). Without casting this to dynamic, this would not compile.

private class DateTimeConverter<T> : JsonConverter<T> { public override void Write(Utf8JsonWriter writer, T date, JsonSerializerOptions options) { writer.WriteStringValue((date as dynamic).ToString("MM/dd/yyyy")); //US date style } public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { //Don't need to implement this unless you're using this to deserialize too throw new NotImplementedException(); } }
Code language: C# (cs)

Without the assumption about the types were dealing with, it wouldn’t be safe to call .ToString(format) like this. If this generic class were public, then it would be possible to use it with types other than the datetime types, potentially leading to runtime exceptions.

We can make the assumption because of the CanConvert() method restricting what types the factory can deal with:

public override bool CanConvert(Type typeToConvert) { return typeToConvert == typeof(DateTime) || typeToConvert == typeof(DateTime?) || typeToConvert == typeof(DateTimeOffset) || typeToConvert == typeof(DateTimeOffset?); }
Code language: C# (cs)

Is it necessary to use dynamic typing? No. You could have if-else statements checking the type, casting to the proper type, and calling ToString(format) in each if block. But then you would have duplicate logic.

JsonConverter<object> and JsonConverter<dynamic> don’t work

You may be thinking, do I really need to use a custom converter factory? Can’t I just use a custom converter with object or dynamic?

No, neither of these approaches work:

public class ObjectConverter : JsonConverter<object>
Code language: C# (cs)
public class DynamicConverter : JsonConverter<dynamic>
Code language: C# (cs)

When the JsonSerializer tries to create the converter, it runs into this exception:

System.InvalidCastException: Unable to cast object of type ‘JsonDateParsing.ObjectConverter’ to type ‘System.Text.Json.Serialization.JsonConverter1[System.Nullable1[System.DateTime]]’.

Leave a Comment