Skip to content

System.Text.Json: (De)serialization support for quoted numbers  #30255

@NickCraver

Description

@NickCraver
Original proposal by @NickCraver (click to view)

Apologies if this issue exists already...I hunted and couldn't find one.

I've been trying to switch a lot of usages over to System.Text.Json from Newtonsoft and Jil, but a recurring theme is handling external APIs and their not-quite-right returns of JSON.

Unfortunately, an example I'm seeing over and over is:

{ "field":"12345" }

They're numbers, but as strings. int, double, decimal, whatever. It happens all the time. And since it's someone's API, it's unlikely we'll get the world to fix this.

Note: this is something Newtonsoft handles by default, which is why I don't think most people realize it's an issue.

Is there any chance we can let System.Text.Json handle number types being quoted when deserializing? Something a user opts-into via JsonSerializerOptions?

Small repro example:

void Main()
{
	JsonSerializer.Parse<Foo>(@"{""Bar"":""1234""}");
}
public class Foo
{
	public int Bar { get; set; }
}

cc @ahsonkhan @steveharter


Edited by @layomia:

Users want to be able to deserialize JSON strings into number properties. Scenarios include deserializing "NaN", "Infinity" and "-Infinity" (#31024).

We should add an option to enable this scenario, and possibly allow serializing numbers as strings.

These semantics allow better interop with various API endpoints accross the web.

API Proposal

namespace System.Text.Json
{
    public partial sealed class JsonSerializerOptions
    {
        public JsonNumberHandling NumberHandling { get; set; }
    }
}

namespace System.Text.Json.Serialization
{
    [Flags]
    public enum JsonNumberHandling : byte
    {
        /// <summary>
        /// No specified number handling behavior. Numbers can only be read from <see cref="JsonTokenType.Number"/> and will only be written as JSON numbers (without quotes).
        /// </summary>
        None = 0x0,
        /// <summary>
        /// Numbers can be read from <see cref="JsonTokenType.String"/>. Does not prevent numbers from being read from <see cref="JsonTokenType.Number"/>.
        /// </summary>
        AllowReadingFromString = 0x1,
        /// <summary>
        /// Numbers will be written as JSON strings (with quotes), not as JSON numbers.
        /// </summary>
        WriteAsString = 0x2,
        /// Floating point constants represented as <see cref="JsonTokenType.String"/> tokens
        /// such as "NaN", "Infinity", "-Infinity", can be read when reading, and such CLR values
        /// such as <see cref="float.NaN"/>, <see cref="double.PositiveInfinity"/>, <see cref="float.NegativeInfinity"/> will be written as their corresponding JSON string representations.
        AllowNamedFloatingPointLiterals = 0x4
    }

    public partial sealed class JsonNumberHandlingAttribute : JsonAttribute
    {
        public JsonNumberHandling Handling { get; }
    
        public JsonNumberHandlingAttribute(JsonNumberHandling handling)
        {
            Handling = handling;
        }
    }
}

Usage

Allow reading numbers from strings; write numbers as strings.

public class ClassWithInts
{
    public int NumberOne { get; set; }
    public int NumberTwo { get; set; }
}

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString 
};

string json = @"{""Number1"":1,""Number2"":""2""}";

ClassWithInts @class = JsonSerializer.Deserializer<ClassWithInts>(json, options);
Console.WriteLine(@class.NumberOne); // 1
Console.WriteLine(@class.NumberTwo); // 2

json = JsonSerializer.Serialize(@class, options);
Console.WriteLine(json); // @"{""Number1"":""1"",""Number2"":""2""}";

Allow reading numbers from floating point constants; write floating point constants

Given a class:

public class ClassWithNumbers
{
    public int IntNumber { get; set; }
    public float FloatNumber { get; set; }
}

Without the new option, reading floating-point-constant representations fails:

string json = @"{""IntNumber"":1,""FloatNumber"":""NaN""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json);

// Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to System.Single. Path: $.FloatNumber | LineNumber: 0 | BytePositionInLine: 34.

Writing also fails:

var obj = new ClassWithNumbers
{
    IntNumber = -1,
    FloatNumber = float.NaN
};

string json = JsonSerializer.Serialize(obj);

// Unhandled exception. System.ArgumentException: .NET number values such as positive and negative infinity cannot be written as valid JSON.

With the new option, reading floating-point-constant representations works:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};

string json = @"{""IntNumber"":1,""FloatNumber"":""NaN""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);

Console.WriteLine(obj.IntNumber); // 1
Console.WriteLine(obj.FloatNumber); // NaN

Writing floating-point-constant representations also works:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};

var obj = new ClassWithNumbers
{
    IntNumber = -1,
    FloatNumber = float.NaN
};

string json = JsonSerializer.Serialize(obj, options);
Console.WriteLine(json); // {"IntNumber":-1,"FloatNumber":"NaN"}

Allow reading numbers from string; support reading and writing floating point constants

Reading:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.AllowNamedFloatingPointLiterals
};

string json = @"{""IntNumber"":""1"",""FloatNumber"":""NaN""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);

Console.WriteLine(obj.IntNumber); // 1
Console.WriteLine(obj.FloatNumber); // NaN

Writing:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowNamedFloatingPointLiterals
};

var obj = new ClassWithNumbers
{
    IntNumber = -1,
    FloatNumber = float.NaN
};

string json = JsonSerializer.Serialize(obj, options);
Console.WriteLine(json); // {"IntNumber":-1,"FloatNumber":"NaN"}

Write numbers as strings; support reading and writing floating point constants

Reading:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowNamedFloatingPointLiterals
};
string json = @"{""IntNumber"":""1""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);

Console.WriteLine(obj.IntNumber); // 1
Console.WriteLine(obj.FloatNumber); // 0
string json = @"{""IntNumber"":""1"",""FloatNumber"":""NaN""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);

// Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to System.Int32. Path: $.IntNumber | LineNumber: 0 | BytePositionInLine: 16.

Writing:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowNamedFloatingPointLiterals
};

var obj = new ClassWithNumbers
{
    IntNumber = -1,
    FloatNumber = float.NaN
};

string json = JsonSerializer.Serialize(obj, options);
Console.WriteLine(json); // {"IntNumber":"-1","FloatNumber":"NaN"}

Read semantics are the same as the serializer's default (no quotes)

For example, integers cannot have decimal places:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString
}

string json = @"{""IntNumber"":""1.0""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);
// Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to System.Int32. Path: $.IntNumber | LineNumber: 0 | BytePositionInLine: 17.

Similarly, there can't be any leading or trailing trivia:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString
}

string json = @"{""IntNumber"":""1a""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);
// Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to System.Int32. Path: $.IntNumber | LineNumber: 0 | BytePositionInLine: 15.

Leading or trailing whitespace is also not allowed:

var options = new JsonSerializerOptions
{
    NumberHandling = JsonNumberHandling.AllowReadingFromString
}

string json = @"{""IntNumber"":""1 ""}";
ClassWithNumbers obj = JsonSerializer.Deserialize<ClassWithNumbers>(json, options);
// Unhandled exception. System.Text.Json.JsonException: The JSON value could not be converted to System.Int32. Path: $.IntNumber | LineNumber: 0 | BytePositionInLine: 15.

Note that formats such as the currency and percentage formats are not allowed. Commas (,), the percent symbol (%), and underscores (_) are not permitted in input strings.

Notes

Newtonsoft compat

  • Newtonsoft.Json allows implicit conversions from strings to numbers on deserialization, i.e, no custom logic or setting is required. We will require that users explictly opt in to this behavior.
  • Newtonsoft.Json does not provide a built-in way to serialize numbers as strings. Custom logic, e.g. a custom converter needs to provided for this behavior. This proposal includes an easy way to specify this behavior.

Read/write semantics

  • The number reading and writing behavior of this feature is exactly the same as if no quotes were specified, except that surrounding quotes can be allowed on deserialization, and can be written on serialization.
    • Reading/writing is not culture aware (i.e InvariantCulture). A custom converter has to be specified for culture-aware handling.
    • The "G17" standard numeric format is used when reading and writing double representations.
    • The "G9" standard format is used when reading and writing float representations.
    • Percentage and currency formats are not supported.
  • Numbers represented as JsonTokenType.String tokens will be unescaped if needed when deserializing. This is in keeping with other string-based parsing for types like DateTime and Guid.
  • Only the literal "NaN", "Infinity", and "-Infinty" values are allowed. This is in keeping with the most common variations on the web. The matching is case-sensitive. We can be more permissive in the future, based on user feedback.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions