JSON::Any#as_f unpredictable when number might sometimes be an integer

I’m writing a Crystal program that continuously polls an IoT device on my local network and gets back a real-time power measurement in Watts. The response is a JSON like {"voltage": 120.68, "power": 16.45}. As you can see, the values are floating point numbers which have been rounded or truncated by the device.

I’d like to calculate current, and my hope was that simply this would work:

data = JSON.parse("{\"voltage\": 120.68, \"power\": 16.45}")
current = data["power"].as_f / data["voltage"].as_f

However, a problem occurs occasionally but frequently enough that I run into it within not too many samples! When the voltage reading rounds/truncates to a integer, I get back a JSON response from the endpoint like this: {"voltage" => 121, "power" => 16.53}. Notice 121.

This case breaks data["voltage"].as_f with an exception, Unhandled exception: cast from Int64 to Float64 failed, at /usr/share/crystal/src/json/any.cr:192:5:192 (TypeCastError)

I’ve had to resort to this workaround:

data = JSON.parse("{\"voltage\": 121, \"power\": 16.53}")
power = data["power"].raw.is_a?(Float) ? data["power"].as_f : data["power"].as_i.to_f
voltage = data["voltage"].raw.is_a?(Float) ? data["voltage"].as_f : data["voltage"].as_i.to_f

This works but seems unnecessarily complicated.

It also seems like I was lucky that this case happened often enough that I discovered the exception quickly. If someone was developing in a case where they were parsing floating point numbers and it happened to be an integer only rarely, this bug might go undiscovered for a long time.

Is there a better way of doing this in Crystal as-is? Or should changes to JSON::Any#as_f be considered?

My gut feeling is that any time someone calls JSON::Any#to_f, they’d probably like an integer to parse just fine. :innocent:

See Improve auto-casting in JSON::Any · Issue #8618 · crystal-lang/crystal · GitHub and Inconsistent UX with float parsing between Any and Float64 · Issue #10438 · crystal-lang/crystal · GitHub

Perfect – I wasn’t able to find those when I searched. I might make a comment on the issue to provide a use-case. Thank you!

I still think you don’t really even need JSON::Any here. Just do Hash(String, Float64).from_json "{\"voltage\": 121, \"power\": 16.53}" and call it a day.

I think as_f should work with int values too. I said it in the past.

1 Like