Why doesn't System.Text.Json.JsonElement have TryGetString() or TryGetBoolean()

Viewed 4641

I'm parsing some JSON data with the .NET Core System.Text.Json namespace which returns JsonElement objects.

For Int32 types, for example, JsonElement has a GetInt32() which will return the value as an integer or throw an exception if it isn't an integer, and there is also a TryGetInt32() which copies the parsed value to an out variable and returns true or false depending on whether it was able to parse correctly.

The same applies to almost all other primitive types but for some reason, GetBoolean() and GetString() have no try... equivalent even though they also will throw an exception if the value cannot be parsed correctly.

This seems such an obvious oversight, it makes me think I'm doing something wrong. Can anyone explain why they aren't needed?

2 Answers

UPD

Don't mind the original answer, the TryGet_number_type methods don't work as I (and I assume you) would expect - they will throw if you will try to get "number_type" from element which ValueKind is not a Number (decimal docs for example).

So this TryGet... API basically tries to parse inner value as some concrete type but only if value is of valid json type for this attempted concrete type(Number for all numeric types, String for Guid, DateTime and DateTimeOffset), otherwise it will throw InvalidOperationException, therefore it would not make sense to have an TryGetString and TryGetBoolean methods cause there is no ambiguity here (string is always string and boolean is always boolean) and they would behave exactly the same as Get counterpart.

Original answer:

Was not able to find any reasoning for not having this API's, but implementing them yourself should not be a big issue (still would be nice to have them in standard library):

According to docs GetBoolean throws if value's ValueKind is neither True nor False.

public static bool TryGetBoolean(this JsonElement je, out bool parsed)
{
    var (p, r) = je.ValueKind switch
    {
        JsonValueKind.True => (true, true),
        JsonValueKind.False => (false, true),
        _ => (default, false)
    };    
    parsed = p;
    return r;
}

And GetString throws if value's ValueKind is neither String nor Null:

public static bool TryGetsString(this JsonElement je, out string parsed)
{
    var (p, r) = je.ValueKind switch
    {
        JsonValueKind.String => (je.GetString(), true),
        JsonValueKind.Null => (null, true),
        _ => (default, false)
    };  
    parsed = p;
    return r;
}

And sample test:

using (JsonDocument document = JsonDocument.Parse(@"{""bool"": true, ""str"": ""string""}"))
{
    if (document.RootElement.GetProperty("bool").TryGetBoolean(out var b))
    {
        Console.WriteLine(b);
    }

    if (document.RootElement.GetProperty("str").TryGetString( out var s))
    {
        Console.WriteLine(s);
    }
}

The answer seems to be hinted at (but not fully explained) by a note in the remarks in the documentation:

This method does not parse the contents of a JSON string value.

But I was still confused until I found some comments in a github issue describing these methods. Here is a snippet of that comment (slightly elided, ** added by me):

// InvalidOperationException if Type is not True or False
public bool GetBoolean();

// InvalidOperationException if Type is not Number 
// FormatException if value does not fit 
public decimal GetDecimal(); 
public double GetDouble(); 
public int GetInt32(); 

// InvalidOperationException if Type is not Number
// false if value **does not fit.** 
public bool TryGetDecimal(out decimal value); 
public bool TryGetDouble(out double value); 
public bool TryGetInt32(out int value);

So, it ultimately comes down to the difference between a FormatException and an InvalidOperationException.

The latter is used to indicate that the token's ValueKind (Number, String, True, False) does not match the expected type. The former (FormatException) is used a bit off-label from what one would normally expect; instead of being thrown for parsing errors* (ie. "1.sg" is not an int), it is thrown for out of range errors!

This is better understood if we look at the numeric overloads first. The non-Try variants either return a value or throw one of two exceptions: InvalidOperationException if the values are not numbers, FormatExceptions if they don't fit. From the documentation for GetInt32:

Exceptions

InvalidOperationException

This value's ValueKind is not Number.

FormatException

The value cannot be represented as an Int32.

Compare this to the Try variants which throw one exception-- InvalidOperationException if the type is not a number--but return false if the value does not fit. From the documentation for TryGetInt32:

Exceptions

InvalidOperationException

This value's ValueKind is not Number.

Returns

Boolean true if the number can be represented as an Int32; otherwise, false.

In this case, "doesn't fit" means that the value is too large/small for the underlying type, ie. greater than int.MaxValue when using [Try]GetInt32

Now let us get back to the case of booleans where you've rightly noticed there is only a non-Try variant. Looking at the comments in the same github issue we see this:

// InvalidOperationException if Type is not True or False
public bool GetBoolean();

And the documentation:

Exceptions

InvalidOperationException

This value's  ValueKind is neither True nor False.

What's missing here is the FormatException and the "doesn't fit" case. As we've seen above in the case of numbers the Try variant gives us detection of "yes this is a number, but it's out of the appropriate range". Booleans only have two possible values--true and false--there's no range to detect, there's no FormatException to distinguish from. Similar with strings--its either a string token or its not.

What's important to note is that in all cases an InvalidOperationException is thrown if the ValueKind doesn't match what is expected by the method. A hypothetical TryGetBoolean wouldn't return false if it encountered a string value of "abc", it would throw an InvalidOperationException because the ValueKind wasn't True or False. But that's already what GetBoolean does! So theres no need for a seperate method.

* Note: There's no parsing error because the methods don't actually attempt to parse numbers/bools inside of a json string token, they only consider values of the correct token type. In otherwords, quoted numbers/bools are not currently supported:

{
   "number": 1234,
   "notNumber": "1234",
   "bool": true,
   "notBool": "false"
}

There is currently (2020-05-30) a request to add support for this. At which time we may see a change to the availability/functionality of the TryGet methods.

Related