To the date of writing, the NumberHandling property is available only in .NET 5.0 and .NET 6.0 RC, which I can't use.
Unfortunately, the string to number converter by itminus didn't work for me either.
So I made another solution that handles different number types and also their nullable variants. I tried to make the code as DRY as possible.
Number and nullable number types
First, the main generic classes for string-to-number and string-to-nullable-number conversion:
public delegate T FromStringFunc<T>(string str);
public delegate T ReadingFunc<T>(ref Utf8JsonReader reader);
public delegate void WritingAction<T>(Utf8JsonWriter writer, T value);
public class StringToNumberConverter<T> : JsonConverter<T> where T : struct
{
protected ISet<TypeCode> AllowedTypeCodes { get; }
protected FromStringFunc<T> FromString { get; }
protected ReadingFunc<T> ReadValue { get; }
protected WritingAction<T> WriteValue { get; }
public StringToNumberConverter(ISet<TypeCode> allowedTypeCodes, FromStringFunc<T> fromString, ReadingFunc<T> read, WritingAction<T> write)
: base()
{
AllowedTypeCodes = allowedTypeCodes;
FromString = fromString;
ReadValue = read;
WriteValue = write;
}
public override bool CanConvert(Type typeToConvert)
{
return AllowedTypeCodes.Contains(Type.GetTypeCode(typeToConvert));
}
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.String)
{
var s = reader.GetString();
return FromString(s);
}
if (reader.TokenType == JsonTokenType.Number)
return ReadValue(ref reader);
using JsonDocument document = JsonDocument.ParseValue(ref reader);
throw new Exception($"unable to parse {document.RootElement} to number");
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
WriteValue(writer, value);
}
}
public class StringToNullableNumberConverter<T> : JsonConverter<T?> where T : struct
{
private readonly StringToNumberConverter<T> stringToNumber;
protected WritingAction<T> WriteValue { get; }
public StringToNullableNumberConverter(ISet<TypeCode> allowedTypeCodes, FromStringFunc<T> fromString, ReadingFunc<T> read, WritingAction<T> write)
: base()
{
stringToNumber = new StringToNumberConverter<T>(allowedTypeCodes, fromString, read, write);
WriteValue = write;
}
public override bool CanConvert(Type typeToConvert)
{
return stringToNumber.CanConvert(Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert);
}
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return stringToNumber.Read(ref reader, typeToConvert, options);
}
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
if (!value.HasValue)
writer.WriteNullValue();
else
stringToNumber.Write(writer, value.Value, options);
}
}
Then a util class that simplifies their usage. It holds non-generalizable, type-exact conversion methods and settings:
static class StringToNumberUtil
{
public static readonly ISet<TypeCode> intCodes = new HashSet<TypeCode> { TypeCode.Byte, TypeCode.Int16, TypeCode.Int32 };
public static readonly ISet<TypeCode> longCodes = new HashSet<TypeCode> { TypeCode.Int64 };
public static readonly ISet<TypeCode> decimalCodes = new HashSet<TypeCode> { TypeCode.Decimal };
public static readonly ISet<TypeCode> doubleCodes = new HashSet<TypeCode> { TypeCode.Double };
public static int ParseInt(string s) => int.Parse(s, CultureInfo.InvariantCulture);
public static long ParseLong(string s) => long.Parse(s, CultureInfo.InvariantCulture);
public static decimal ParseDecimal(string s) => decimal.Parse(s, CultureInfo.InvariantCulture);
public static double ParseDouble(string s) => double.Parse(s, CultureInfo.InvariantCulture);
public static int ReadInt(ref Utf8JsonReader reader) => reader.GetInt32();
public static long ReadLong(ref Utf8JsonReader reader) => reader.GetInt64();
public static decimal ReadDecimal(ref Utf8JsonReader reader) => reader.GetDecimal();
public static double ReadDouble(ref Utf8JsonReader reader) => reader.GetDouble();
public static void WriteInt(Utf8JsonWriter writer, int value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
public static void WriteLong(Utf8JsonWriter writer, long value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
public static void WriteDecimal(Utf8JsonWriter writer, decimal value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
public static void WriteDouble(Utf8JsonWriter writer, double value) => writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
Finally, you can define convenience classes for individual number types...
public class StringToIntConverter : StringToNumberConverter<int>
{
public StringToIntConverter()
: base(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt)
{
}
}
public class StringToNullableIntConverter : StringToNullableNumberConverter<int>
{
public StringToNullableIntConverter()
: base(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt)
{
}
}
... and registered them in the JsonSerializerOptions like this:
var options = new JsonSerializerOptions {
...
};
options.Converters.Add(new StringToIntConverter());
options.Converters.Add(new StringToNullableIntConverter());
...
(Or register the converters straight away, if you like.)
options.Converters.Add(new StringToNumberConverter<int>(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt));
options.Converters.Add(new StringToNullableNumberConverter<int>(StringToNumberUtil.intCodes, StringToNumberUtil.ParseInt, StringToNumberUtil.ReadInt, StringToNumberUtil.WriteInt));
Numbers that should deserialize as enums
You can add this if your JSON contains string-encoded numeric attributes, whose values have predefined meaning representable as an enum.
public class StringToIntEnumConverter<T> : JsonConverter<T> where T : struct, System.Enum
{
private StringToIntConverter stringToInt = new StringToIntConverter();
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert == typeof(T);
}
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
int val = stringToInt.Read(ref reader, typeToConvert, options);
string underlyingValue = val.ToString(CultureInfo.InvariantCulture);
return (T)Enum.Parse(typeof(T), underlyingValue);
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
var number = Convert.ChangeType(value, Enum.GetUnderlyingType(typeof(T)), CultureInfo.InvariantCulture);
writer.WriteStringValue(number.ToString());
}
}
public class StringToNullableIntEnumConverter<T> : JsonConverter<T?> where T : struct, System.Enum
{
private StringToIntEnumConverter<T> stringToIntEnum = new StringToIntEnumConverter<T>();
public override bool CanConvert(Type typeToConvert)
{
return stringToIntEnum.CanConvert(Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert);
}
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return stringToIntEnum.Read(ref reader, typeToConvert, options);
}
public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options)
{
if (!value.HasValue)
{
writer.WriteNullValue();
return;
}
stringToIntEnum.Write(writer, value.Value, options);
}
}
Usage in JsonSerializerOptions:
var options = new JsonSerializerOptions {
...
};
options.Converters.Add(new StringToIntEnumConverter<OrderFlags>());
options.Converters.Add(new StringToNullableIntEnumConverter<OrderFlags>());
...
System.InvalidCastException: Unable to cast object of type 'System.Collections.Generic.List
1[System.String]' to type 'System.Collections.Generic.IList1[System.Object]'
when deserializing an array like [12345] to a string[] field. Also you won't need to override the CanConvert() method. – Xenophanes