How an Empty String in JavaScript Ruined My Afternoon

The latest subtle JavaScript behaviour which gave me a tough time debugging
By Yewo Mhango | Thu 28 Dec 2023

A couple weeks ago, I was writing some React code for a car website app, where each vehicle model had a dimensions field. The dimensions field contained the width, height and length dimensions in this format: 4.5×1.8×1.7 m. On this specific day, I was creating an interface to edit and create the vehicles on the site.

The vehicle model already had lots of fields, and I didn’t want to be creating lots of duplicate state variables, so I decided that I would just be modifying the original vehicle model directly. That was simple enough for most of the fields, as I could just write the code as shown below (the code samples are heavily simplified for illustration):

// ... state declarations, etc

let updateField = (fieldName, value) =>
    setVehicle({ ...vehicle, [fieldName]: value });

return (
    <>
        <TextField
            label="Vehicle name"
            defaultValue={vehicle.name}
            onInput={(e) => updateField("name", e.target.value)}
        />
        ... other fields
    </>
);

But for the dimensions fields, I wanted to make sure that it would be entered in a correct and uniform format, with the width, height, and length components in the correct order. So I decided that I would create three separate inputs on the form for each dimension, but have them update their own parts of the same dimensions field of the vehicle model.

So what I decided on was that I would just use a Regular Expression on each re-render to extract the three numbers from the field, and assign any changes back directly to the dimensions field, without declaring intermediate state variables. To give you an idea, the code was roughly something like this:

// ... other code

const DIMENSIONS_REGEX = /([0-9\.]+)×([0-9\.]+)×([0-9\.]+) m/

let [, length, width, height] = DIMENSIONS_REGEX.exec(state.dimensions);

const onInputWidth = (event) => {
    const value = event.target.value;
    if (!Number.isNaN(Number(value))) {
        updateField(
            "dimensions",
            `${length}×${value}×${height} m`
        );
    }
};

// similarly, event handlers for height and length

return (
    <>
        ... other components and fields
        <TextField
            value={width}
            type="number"
            label="Width (Meters)"
            onInput={onInputWidth}
        />
    </>
);

Of course, having iterated on it, I realized that a simpler (and better) approach would have been to just use a form, and update the data upon submitting the form, but for the purposes of this article I just want to provide context for the error I encountered in my initial version, which prompted me to write this article.

Anyway, if you look closely at the event handler for the input field, I had included a check in the if-statement to ensure that the value which the user had provided was actually a valid number, before updating the state:

const onInputWidth = (event) => {
    const value = event.target.value;
    if (!Number.isNaN(Number(value))) {
        // ... update the field
    }
};

The condition !Number.isNaN(Number(value)) is meant to check whether the user input is actually a valid number or not. Calling Number(value) attempts to convert the value string into a number. If the string is not a valid number, it returns the specific value NaN (A floating-point value meaning "Not a Number" in JavaScript), and otherwise it just returns the resulting number. Next, the value is passed to the Number.isNaN function to check whether the value provided is the aforementioned NaN value or not, and hence whether the number was determined to be a valid number.

Even here, if you don’t know well enough, there is the potential pitfall that you could try to check whether it’s a valid number with the condition typeof Number(value) === "number". But, surprise surprise, typeof NaN is actually "number" as well. In other words, the type of a value which is defined as "not a number" is actually... a number. Yeah, bizarre, I know. So that’s why you’re supposed to use Number.isNaN rather than typeof.

But, while I had managed to escape that pitfall, I actually still encountered another problem. Whenever I would erase everything in the text field (leaving it empty), the whole app would crash. So, I looked at the error in the console, and it seemed to be coming from the regular expression, where I was trying to break down the dimensions.

I added some console.log statements before that particular line of code to inspect the vehicle state. In doing so, I found that whenever I left the text field empty, the last value of the dimensions field (before crashing) would be something like this: 2××1 m. This in turn would fail to match the regular expression, which would cause a crash since I was immediately trying to destructure the regular expression array, when it would instead be a null value.

I thought that it shouldn’t have been possible for that blank dimension to slip through, since the whole purpose of the check I had added in the event handler callbacks was that the dimensions field should only be updated if the user input provided was a valid number. And an empty string is not a valid number, right?

Well, I initially tried testing and changing lots of other things, with the assumption that an empty string couldn’t slip through as a number there. But the attempts were all unsuccessful. Eventually, I tried to take a closer look at the type check itself by testing that basic assumption. I opened up the browser console and tried parsing an empty string as a number, for the shock of my day:

Attempting to convert an empty string to a number

Who would have thought that calling Number with an empty string would actually return 0? By then, I considered myself to be pretty well-versed with the various tricky behaviours of JavaScript's type coersion, but it turns out that that was not quite enough. My assumption was that calling the Number constructor would return NaN for any invalid string, including an empty string, but my assumption was proven wrong.

This made me feel a bit deflated over the rampantness of subtle footguns like this in JavaScript, but I eventually picked myself up and went back to working on it. I just modified the condition to add a test for whether the string is also empty:

if (value.trim() !== "" && !Number.isNaN(Number(value))) {
    // ...
}