2

.NET 6, C# 10, System.Text.Json

Assuming a JsonElement that has a ValueKind of Number, the JsonElement's value could have an underlying type of int, decimal, double, etc. To find this underlying type, one could run the through a series of tests such as TryGetInt32(), TryGetInt64(), TryGetDouble(), etc.

Is this the best practice, or is there a more idiomatic way?

chrisxfire
  • 445
  • 1
  • 3
  • 12
  • You should be able to use just `TryGetDecimal`, as the `Decimal` type is able to represent all JSON `number` values. – Dai Jun 13 '22 at 03:32

1 Answers1

3

TL;DR:

  • If you know the JSON number value will be an integer, then use GetInt64 /TryGetInt64.

    • Don't use GetInt32 or TryGetInt32 unless you know you can.
  • Otherwise, just use Decimal, as in:JsonElement.TryGetDecimal and JsonElement.GetDecimal.

  • The .NET Decimal type can represent practically every JSON number value with full precision, regardless of if it's an integer number, e.g. { "value": 1234 } or a non-integer number, e.g. { "value": 12.34 } in the source JSON.

    • ...the only exception is JSON numbers outside the range -296 to 296 or with precision beyond 2 / 1028.
      • That's 79,228,162,514,264,337,593,543,950,335 and 0.0000000000000000000000000002 respectively.
    • It's highly unlikely you'll encounter those kinds of extreme values in a JSON document because even though the JSON spec itself imposes no numeric limits, most JS environments and JSON libraries operate on the assumption that integer numbers never exceed JS's Number.MAX_SAFE_INTEGER (that's 253, or 9,007,199,254,740,991).
      • ...as for non-integer values: that depends entirely on the application: statistical or scientific computing applications might have JSON documents with huge, but imprecise, double values in it - in which case using TryGetDouble or GetDouble would be appropriate, so always check with your project requirements first.
  • In general, avoid using IEEE-754 floating-point types (Single and Double) as they cannot accurately and precisely represent certain types of numbers which would prevent round-tripping and cause data loss or corruption (e.g. try doing ( 0.001f + 0.004f ) - 0.001f).


In detail:

Assuming a JsonElement that has a ValueKind of Number, the JsonElement's value could have an underlying type of int, decimal, double, etc.

This assumption is incorrect: the JsonElement type does not store or represent JSON number values using any specific .NET type: instead the JsonElement struct is simply a view over the source JsonDocument's serialized JSON data: so essentially a JsonElement is really just a (validated) run-of-text over a giant JSON string.

Therefore, when a JsonElement's ValueKind == JsonValueKind.Number it just means it wraps a sequence of characters directly representing the serialized JSON number value, so there is no "underlying" value representation.

...this means that you can use any of the TryGet... methods to get a .NET numeric value from a JSON number value: the only thing you need to consider is if the .NET type can validly represent the JSON number value or not: so...

  • If you know the JSON file will always contain non-null integer values under MAX_SAFE_INTEGER then use GetInt64 (no need for TryGetInt64).
  • If you know the JSON file will always contain non-null integer values under 232 then use GetInt32 (no need for TryGetInt32).
  • If you're unsure if the number might be null or not, but know it will be an integer value, then don't use TryGetInt64 or TryGetInt32, instead check ValueKind == JsonValueKind.Null first, and then use GetInt64 or GetInt32.
    • This approach means that non-null, non-integer values will cause a runtime exception (which you should want, as it's an unexpected exceptional circumstance) instead of incorrectly assuming that if TryGetInt32 returns false then the JsonElement "must" be null, which is wrong.
  • If you're prototyping, using a REPL, or otherwise messing-around or playing fast-and-loose with data validation requirements then using TryGetDecimal is okay, I guess.

Here's a program I wrote to compare how the different methods handle different types of source JSON number values:



Test( @"{ ""value"": 0 }"                );
Test( @"{ ""value"": -1 }"               ); // Negative signed integer.
Test( @"{ ""value"": 512 }"              ); 
Test( @"{ ""value"": 1.005 }"            ); // Non-integer value.
Test( @"{ ""value"": 9007199254740992 }" ); // `Number.MAX_SAFE_INTEGER + 1`
Test( @"{ ""value"": 3.7e-5 }"           ); // Small fractional number.
Test( @"{ ""value"": 9.99e300 }"         ); // Outside the range of Decimal and Single, but within Double's range.

static void Test( String json )
{
    JsonDocument doc = JsonDocument.Parse( json );
    
    JsonElement valueProp = doc.RootElement.GetProperty("value");

    valueProp.Dump();
    
    List<( String method, Boolean ok, Object? value )> list = new();

    // Decimal:
    list.Add( ( nameof(valueProp.TryGetDecimal), ok: valueProp.TryGetDecimal( out Decimal dec ), value: dec ) );

    // IEEE-754:
    list.Add( ( nameof(valueProp.TryGetDouble), ok: valueProp.TryGetDouble( out Double dbl ), value: dbl ) );
    list.Add( ( nameof(valueProp.TryGetSingle), ok: valueProp.TryGetSingle( out Single sng ), value: sng ) );

    // Unsigned integers:
    list.Add( ( nameof(valueProp.TryGetUInt64), ok: valueProp.TryGetUInt64( out UInt64 u64 ), value: u64 ) );
    list.Add( ( nameof(valueProp.TryGetUInt32), ok: valueProp.TryGetUInt32( out UInt32 u32 ), value: u32 ) );
    list.Add( ( nameof(valueProp.TryGetUInt16), ok: valueProp.TryGetUInt16( out UInt16 u16 ), value: u16 ) );
    list.Add( ( nameof(valueProp.TryGetByte), ok: valueProp.TryGetByte  ( out Byte   u8  ), value: u8  ) );

    // Signed integers:
    list.Add( ( nameof(valueProp.TryGetInt64), ok: valueProp.TryGetInt64( out Int64 s64 ), value: s64 ) );
    list.Add( ( nameof(valueProp.TryGetInt32), ok: valueProp.TryGetInt32( out Int32 s32 ), value: s32 ) );
    list.Add( ( nameof(valueProp.TryGetInt16), ok: valueProp.TryGetInt16( out Int16 s16 ), value: s16 ) );
    list.Add( ( nameof(valueProp.TryGetSByte), ok: valueProp.TryGetSByte( out SByte s8  ), value: s8  ) );

    list.Dump();
}

which gives me these results:


Input JSON TryGetDecimal TryGetDouble TryGetSingle TryGetUInt64 TryGetUInt32 TryGetUInt16 TryGetByte TryGetInt64 TryGetInt32 TryGetInt16 TryGetSByte
{ "value": 0 } 0 0 0 0 0 0 0 0 0 0 0
{ "value": -1 } -1 -1 -1 -1 -1 -1 -1
{ "value": 512 } 512 512 512 512 512 512 512 512 512
{ "value": 1.005 } 1.005 1.005 1.005
{ "value": 9007199254740992 } 9007199254740992 9007199254740992 9.007199E+15 9007199254740992 9007199254740992
{ "value": 3.7e-5 } 0.000037 3.7E-05 3.7E-05
{ "value": 9.99e300 } 9.99E+300
Dai
  • 141,631
  • 28
  • 261
  • 374
  • _"the .NET Decimal type can represent every JSON number value with full precision"_ I think this is half right. The range of decimal is smaller than double, so decoding big numbers like "1e300" with `GetDecimal` will give you a `FormatException`, while `GetDouble` works. – shingo Jun 13 '22 at 04:37
  • @shingo You're right, I'll update my answer. – Dai Jun 13 '22 at 04:41
  • 2
    RFC-7159 emphasizes interoperability with IEEE-754 - I would personally recommend using `double` rather than `decimal`; if you're producing or receiving JSON that relies on more precision than `double` can provide, you're either in a *very* specialized situation or you're already running the risk of data loss. (I'd suggest using strings for that kind of scenario, to ensure that intermediaries don't lose precision.) – Jon Skeet Jun 13 '22 at 06:21
  • Thanks @Dai. In general, for other data types outside of JSON `Number`'s (True, False, String, etc), is using the TryGet methods still the most idiomatic way? – chrisxfire Jun 13 '22 at 13:32
  • @chrisxfire "idiomatic" doesn't really apply here - what matters more is how strict or lax you want your JSON processing code to be, and how your program should handle unexpected JSON input. Personally I prefer to fail-fast when any unexpected input is encountered - but other people might have their reasons for being more forgiving (but at risk of letting invalid data through, or failing to handle all classes of input, etc) - but all of this is moot if you use a JSON Schema validation library: if the input is validated as being schema-conforming then you won't need any `TryGet...` methods. – Dai Jun 13 '22 at 13:53