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: 1) Create a custom JsonConverter for each type or 2) Create a JsonConverterFactory + generic custom JsonConverter.
To do the JsonConvertFactory approach:
- Subclass JsonConvertFactory.
- Implement CanConvert() to handle all the target types you want to handle.
- Create a generic custom JSON converter as a nested private class.
- Implement CreateConverter() to return an instance of the custom converter based on the type.
The following code shows an example of how to do this approach:
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)
Now you can use the JsonConverterFactory by adding it to JsonSerializerOptions.Converters. Here’s an example:
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 serialized the object to JSON and used the JsonConverterFactory for the DateTime properties:
{
"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 JsonConverterFactory object.
In this article, I’ll go into more details about this JsonConverterFactory approach and how it compares with creating multiple converters.
Table of Contents
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” date format string. To de-dupe the logic, you’d have to resort to dynamic typing. The JsonConverterFactory 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 JsonConverterFactory approach, you only need to pass in a single converter object.
The JsonConverterFactory 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 JsonConverterFactory? 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.JsonConverter
1[System.Nullable
1[System.DateTime]]’.