46

Using .Net Core 3's new System.Text.Json JsonSerializer, how do you automatically cast types (e.g. int to string and string to int)? For example, this throws an exception because id in JSON is numeric while C#'s Product.Id is expecting a string:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        var json = @"{""id"":1,""name"":""Foo""}";
        var o = JsonSerializer.Deserialize<Product>(json, new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
        });

        return View();
    }
}

public class Product
{
    public string Id { get; set; }
    public string Name { get; set; }
}

Newtonsoft's Json.Net handled this beautifully. It didn't matter if you were passing in a numeric value while C# was expecting a string (or vice versa), everything got deserialized as expected. How do you handle this using System.Text.Json if you have no control over the type format being passed in as JSON?

Ahmad Ibrahim
  • 1,915
  • 2
  • 15
  • 32
Johnny Oshika
  • 54,741
  • 40
  • 181
  • 275

7 Answers7

56

Edit: You can use JsonNumberHandlingAttribute and it handles everything correctly in 1 line, no need to write any code:

[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
public string Id { get; set; }
....

Original answer:

  1. The new System.Text.Json api exposes a JsonConverter api which allows us to convert the type as we like.

    For example, we can create a generic number to string converter:

    public class AutoNumberToStringConverter : JsonConverter<object>
    {
        public override bool CanConvert(Type typeToConvert)
        {
            return typeof(string) == typeToConvert;
        }
        public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if(reader.TokenType == JsonTokenType.Number) {
                return reader.TryGetInt64(out long l) ?
                    l.ToString():
                    reader.GetDouble().ToString();
            }
            if(reader.TokenType == JsonTokenType.String) {
                return reader.GetString();
            }
            using(JsonDocument document = JsonDocument.ParseValue(ref reader)){
                return document.RootElement.Clone().ToString();
            }
        }
    
        public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
        {
            writer.WriteStringValue( value.ToString());
        }
    }
    
  2. When working with MVC/Razor Page, we can register this converter in startup:

    services.AddControllersWithViews().AddJsonOptions(opts => {
        opts.JsonSerializerOptions.PropertyNameCaseInsensitive= true;
        opts.JsonSerializerOptions.Converters.Insert(0, new AutoNumberToStringConverter());
    });
    

    and then the MVC/Razor will handle the type conversion automatically.

  3. Or if you like to control the serialization/deserialization manually:

    var opts = new JsonSerializerOptions {
        PropertyNameCaseInsensitive = true,
    };
    opts.Converters.Add(new AutoNumberToStringConverter());
    var o = JsonSerializer.Deserialize<Product>(json,opts) ;
    
  4. In a similar way you can enable string to number type conversion as below :

    public class AutoStringToNumberConverter : JsonConverter<object>
    {
        public override bool CanConvert(Type typeToConvert)
        {
            // see https://stackoverflow.com/questions/1749966/c-sharp-how-to-determine-whether-a-type-is-a-number
            switch (Type.GetTypeCode(typeToConvert))
            {
                case TypeCode.Byte:
                case TypeCode.SByte:
                case TypeCode.UInt16:
                case TypeCode.UInt32:
                case TypeCode.UInt64:
                case TypeCode.Int16:
                case TypeCode.Int32:
                case TypeCode.Int64:
                case TypeCode.Decimal:
                case TypeCode.Double:
                case TypeCode.Single:
                return true;
                default:
                return false;
            }
        }
        public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            if(reader.TokenType == JsonTokenType.String) {
                var s = reader.GetString() ;
                return int.TryParse(s,out var i) ? 
                    i :
                    (double.TryParse(s, out var d) ?
                        d :
                        throw new Exception($"unable to parse {s} to number")
                    );
            }
            if(reader.TokenType == JsonTokenType.Number) {
                return reader.TryGetInt64(out long l) ?
                    l:
                    reader.GetDouble();
            }
            using(JsonDocument document = JsonDocument.ParseValue(ref reader)){
                throw new Exception($"unable to parse {document.RootElement.ToString()} to number");
            }
        }
    
    
        public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
        {
            var str = value.ToString();             // I don't want to write int/decimal/double/...  for each case, so I just convert it to string . You might want to replace it with strong type version.
            if(int.TryParse(str, out var i)){
                writer.WriteNumberValue(i);
            }
            else if(double.TryParse(str, out var d)){
                writer.WriteNumberValue(d);
            }
            else{
                throw new Exception($"unable to parse {str} to number");
            }
        }
    }
    
Dave
  • 645
  • 7
  • 8
itminus
  • 23,772
  • 2
  • 53
  • 88
  • 3
    Shouldn't the converter in (1) be JsonConverter instead of JsonConverter? The current implementation throws `System.InvalidCastException: Unable to cast object of type 'System.Collections.Generic.List`1[System.String]' to type 'System.Collections.Generic.IList`1[System.Object]'` when deserializing an array like [12345] to a string[] field. Also you won't need to override the CanConvert() method. – SlimShaggy Mar 25 '20 at 17:22
  • 5
    You should serialize and parse numbers in the invariant locale not the current culture locale, e.g. `int.TryParse(s,NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out var i) ` and `double.TryParse(s, NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var d)` – dbc Jun 10 '20 at 13:12
  • StringToNumber and NumberToString worked for me, but adding both gives me error: Unable to cast object of type 'System.Double' to type 'System.Int64'. – Bharat Vasant Mar 02 '21 at 13:21
13

You can use JsonNumberHandlingAttribute in your model class in order to specify how to treat number deserialization. The allowed options are specified in JsonNumberHandling enum.

Example of usage:

public class Product
{
    [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
    public string Id { get; set; }
    
    public string Name { get; set; }
}

If serialization from string to int is required, you can use JsonNumberHandling.AllowReadingFromString

SvjMan
  • 589
  • 9
  • 18
  • 5
    It should be noted that this only applies to use of `System.Text.Json` with the .NET 5.0 or .NET 6.0 Preview 7 (as of August 2021) framework versions. See the link referenced to `JsonNumberHandlingAttribute` in the answer, specifically the "Applies to" section. – Blair Allen Aug 23 '21 at 19:11
  • 13
    Hmm when I place it like this I am getting the `When 'JsonNumberHandlingAttribute' is placed on a property or field, the property or field must be a number or a collection` error – Andrey Stukalin Sep 27 '21 at 08:44
  • 5
    Yeah, I think this only works when you want to deserialize a string as a number, not a number as a string. If you look at the migration guide, it sounds like System.Text.Json doesn't support this natively unfortunately, even on .NET 6: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to?pivots=dotnet-6-0#non-string-values-for-string-properties – olucafont6 Jan 22 '22 at 01:12
10

In the options, set the NumberHandling property to AllowReadingFromString:

var o = JsonSerializer.Deserialize<Product>(json, new JsonSerializerOptions
{
    // [...]
    NumberHandling = JsonNumberHandling.AllowReadingFromString
});
marsze
  • 15,079
  • 5
  • 45
  • 61
  • 2
    Json data was like { "No": 2 } and the type was record Data(string No) it wasnt able to deserialize it. Now i understand AllowReadingFromString was expecting data like { "No": "2" } i mean number as string ... So it is a different case. – Freshblood Jul 16 '21 at 13:52
  • 2
    @Freshblood The option is reading *from* string (deserialize strings as numbers), not deserialize *as* string. Not sure how to do that. – marsze Jul 16 '21 at 14:35
  • 2
    @Freshblood It should be noted that this only applies to use of `System.Text.Json` with the .NET 5.0 or .NET 6.0 Preview 7 (as of August 2021) framework versions. See the link referenced to `NumberHandling` in the answer, specifically the "Applies to" section. – Blair Allen Aug 23 '21 at 19:12
2

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>());
...
zvizesna
  • 43
  • 8
2

For me below solves the issue.

  • Install System.Text.Json Nuget

  • Update startup.cs file with below

    services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString; });

Ravi Khambhati
  • 637
  • 1
  • 12
  • 35
1

Don't worry about it. Just add a property to the class that will return the item you want in the type that you want it.

public class Product
{
    public int Id { get; set; }

    public string IdString 
    {
        get
        {
            return Id.ToString();
        }
    }

    public string Name { get; set; }
}
robbpriestley
  • 3,050
  • 2
  • 24
  • 36
  • 4
    If the client passes the `id` in the JSON as string, then JsonSerializer will throw an exception using your example. – Johnny Oshika Nov 29 '19 at 07:01
  • 2
    So your question was not clear. According to your question, the client is sending in a numeric Id. Now you are saying the client reserves the right to send in either a numeric or a string value in the Id position. – robbpriestley Nov 29 '19 at 16:22
1

Unfortunately for me the example of itminus did not work, here is my variant.

public class AutoNumberToStringConverter : JsonConverter<string>
{
    public override bool CanConvert(Type typeToConvert)
    {
        return typeof(string) == typeToConvert;
    }

    public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.Number)
        {
            if (reader.TryGetInt64(out long number))
            {
                return number.ToString(CultureInfo.InvariantCulture);
            }

            if (reader.TryGetDouble(out var doubleNumber))
            {
                return doubleNumber.ToString(CultureInfo.InvariantCulture);
            }
        }

        if (reader.TokenType == JsonTokenType.String)
        {
            return reader.GetString();
        }

        using var document = JsonDocument.ParseValue(ref reader);
        return document.RootElement.Clone().ToString();
    }

    public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
    {
        writer.WriteStringValue(value);
    }
}
live2
  • 3,771
  • 2
  • 37
  • 46