The + Operator as a Silent Carrier of Bugs

In JavaScript, there’s another surprising operation that might seem mathematical, logical, and consistent to us - I’m talking about the +

Here’s our favorite!

1
1 + '1' // "11"

JavaScript was meant to be a simple and fast language for web designers at the time. But has freeing programmers from static typing always had its advantages?

Let’s go back 28 years to the early versions of JavaScript implemented in the SpiderMonkey engine, specifically JavaScript 1.3, which was included in Netscape Navigator 4.06 in 1998.

1
2
js> help()
JavaScript-C 1.3 1998 06 30

We will focus on an interpreter that supports this behavior.

The entire JSOP_ADD is about 60 lines long, but the decision between “number or string” is made in just five lines. The rest involves protecting values from the garbage collector and manually concatenating a character buffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
case JSOP_ADD:
    rval = rtmp = POP();
    lval = ltmp = POP();

    VALUE_TO_PRIMITIVE(cx, lval, JSTYPE_VOID, &lval);   /* left  → primitive */
    cond = JSVAL_IS_STRING(lval);
    VALUE_TO_PRIMITIVE(cx, rval, JSTYPE_VOID, &rval);   /* right → primitive */

    if (cond || JSVAL_IS_STRING(rval)) {
        /* either side is a string → concatenation
           (js_ValueToString on the other one, JS_malloc, js_strncpy) */
        PUSH_OPND(STRING_TO_JSVAL(str3));
    } else {
        /* neither one is → both go to numbers and get added */
        VALUE_TO_NUMBER(cx, ltmp, d);
        VALUE_TO_NUMBER(cx, rtmp, d2);
        d += d2;
        PUSH_NUMBER(cx, d);
    }
    break;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
	  case JSOP_ADD:
	    rval = rtmp = POP();
	    lval = ltmp = POP();
	    VALUE_TO_PRIMITIVE(cx, lval, JSTYPE_VOID, &lval);
	    if ((cond = JSVAL_IS_STRING(lval)) != 0) {
		/*
		 * Keep lval on the stack so it isn't GC'd during either the
		 * next VALUE_TO_PRIMITIVE or the js_ValueToString(cx, rval).
		 */
		sp[0] = lval;
	    }
	    VALUE_TO_PRIMITIVE(cx, rval, JSTYPE_VOID, &rval);
	    if (cond || JSVAL_IS_STRING(rval)) {
		if (cond) {
		    str = JSVAL_TO_STRING(lval);
		    SAVE_SP(fp);
		    ok = (str2 = js_ValueToString(cx, rval)) != NULL;
		} else {
		    /*
		     * Keep rval on the stack so it isn't GC'd during the next
		     * js_ValueToString.
		     */
		    sp[1] = rval;
		    str2 = JSVAL_TO_STRING(rval);
		    SAVE_SP(fp);
		    ok = (str = js_ValueToString(cx, lval)) != NULL;
		}
		if (!ok)
		    goto out;
		if ((length = str->length) == 0) {
		    str3 = str2;
		} else if ((length2 = str2->length) == 0) {
		    str3 = str;
		} else {
		    length3 = length + length2;
		    chars = JS_malloc(cx, (length3 + 1) * sizeof(jschar));
		    if (!chars) {
			ok = JS_FALSE;
			goto out;
		    }
		    js_strncpy(chars, str->chars, length);
		    js_strncpy(chars + length, str2->chars, length2);
		    chars[length3] = 0;
		    str3 = js_NewString(cx, chars, length3, 0);
		    if (!str3) {
			JS_free(cx, chars);
			ok = JS_FALSE;
			goto out;
		    }
		}
		PUSH_OPND(STRING_TO_JSVAL(str3));
	    } else {
		VALUE_TO_NUMBER(cx, ltmp, d);
		VALUE_TO_NUMBER(cx, rtmp, d2);
		d += d2;
		PUSH_NUMBER(cx, d);
	    }
	    break;

#define BINARY_OP(OP) {                                                       \
    POP_NUMBER(cx, d2);                                                       \
    POP_NUMBER(cx, d);                                                        \
    d = d OP d2;                                                              \
    PUSH_NUMBER(cx, d);                                                       \
}

	  case JSOP_SUB:
	    BINARY_OP(-);
	    break;

	  case JSOP_MUL:
	    BINARY_OP(*);
	    break;

The logic itself is easy to understand, both when applying it to our input data and when analyzing the conditional statements.

1
2
lval  = 1
rval  = '1'

If lval or rval is a string, the entire expression is treated as a string; otherwise, it is converted to a number, that is:

1
2
1 + '1' // "11"
1 + 1 // 2

Looking at this code from 28 years ago, one might wonder: Was it a bug, as in the case of typeof null === "object", or a feature?

Just a quick glance at this code is enough - someone who checks lval and rval and then decides whether they are numbers or strings isn’t doing so by accident.

To make it more fun, let’s try some other math problems

1
2
3
1 * '1' // 1
1 - '1' // 0
1 / '1' // 1

So why do -, *, and / work fine?

Because in this file, a few dozen lines down, subtraction and multiplication are generated by a single macro, without any concatenation logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#define BINARY_OP(OP) {            \
    POP_NUMBER(cx, d2);            \
    POP_NUMBER(cx, d);             \
    d = d OP d2;                   \
    PUSH_NUMBER(cx, d);            \
}

	  case JSOP_SUB:
	    BINARY_OP(-);
	    break;

	  case JSOP_MUL:
	    BINARY_OP(*);
	    break;

+ as an arithmetic operation and string concatenation

We already know that the operator + performs two functions: arithmetic operations and string concatenation. So let’s take a look at how the neighbour from the same camp does it - Java ;-)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import java.util.Scanner;

public class Bug {
    public static void main(String[] args) {
        String input = new Scanner(System.in).nextLine();

        System.out.println(input + 5);

        System.out.println(Integer.parseInt(input) + 5);
    }
}
1
echo 10 | java Bug.java
1
2
105
15

The same error! - Does this mean that a statically typed language can’t prevent this error?

Let’s modify our program:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import java.util.Scanner;

public class Bug {
    public static void main(String[] args) {
        String input = new Scanner(System.in).nextLine();

        System.out.println(input + 5);

        int total = input + 5;

        System.out.println(Integer.parseInt(input) + 5);
    }
}
1
echo 10 | java Bug.java
1
2
3
4
5
Bug.java:9: error: incompatible types: String cannot be converted to int
        int total = input + 5;
                          ^
1 error
error: compilation failed

And that’s the whole difference! - It’s a great idea, but… in Java, the bug is reproducible only when the result doesn’t end up in a context that requires a number.

So basically, only if you write it out right away or append it to another string.

It stops being passed the moment you want to do anything with that value - assign it to int, pass it to a method, store it in an entity field, and so on.

Static typing does not eliminate the mistake. It limits its scope to a single line and turns a silently wrong value into a loud refusal to run.

Why hasn’t this been fixed?

You could say it was a deliberate compromise rather than an oversight. The code we were looking at is from 1998, but the rule itself is three years older. In 1995, there were no developer tools or debuggers. The website’s author had no way to quickly fix such an error, and the page still rendered and was clickable anyway.

With twenty lines of code to change the image on hover, it was a good deal. With two hundred thousand lines of code handling payments, it’s terrible.

The cost is a specific combination of three decisions at once: the same operator handles both concatenation and addition, the result is determined only at runtime, and the direction of the conversion depends on the operator - + pulls toward a string, while - pulls toward a number.

TypeScript - promises it will be a number!

TypeScript - you could call it a “guardian” of JavaScript, but does it solve the problem?

1
const raw = JSON.parse('{"price":"10"}') as { price: number };

We make a beautiful promise - unfortunately, only at compile time.

But what happens at runtime?

We don’t know, because JSON.parse returns any, so TypeScript no longer knows anything about this value beyond our declaration.

1
const sum = raw.price + 5 //"105"

It depends on what gets sent ;-)

Undoubtedly, this is still a big help because it ensures consistency within your code - no one will accidentally pass a string there.

But as doesn’t generate any output code: after compilation, all that remains is a plain JSON.parse.

This isn’t a check - it’s just a promise made to the compiler - and no one verifies it at runtime.

Validation at the boundary

How can you protect yourself from this?

Validate at the boundary of your application, so you can be sure that what you expect matches what you actually receive.

In the following example, we will use zod.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { z } from "zod";

const Product = z.object({ price: z.number() });

const json = '{"price":"10"}';
const data = JSON.parse(json);

console.log(data.price + 5);

Product.parse(data);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
105
Product.parse(data);
        ^

ZodError: [
  {
    "expected": "number",
    "code": "invalid_type",
    "path": [
      "price"
    ],
    "message": "Invalid input: expected number, received string"
  }
]

What the compiler didn’t catch, zod validates at runtime :)

Summary

The + rule has been around for over 30 years and is untouchable due to backward compatibility. What we can do is limit its scope by using tools such as:

  • @typescript-eslint/restrict-plus-operands - an ESLint rule that catches mixed +
  • TypeScript - but only within your own code, not at the system boundary
  • schema validation (zod, valibot, arktype)
  • Number(x) and Number.isFinite

And just how strong this legacy is can be seen in BigInt, which was added to the language in 2020.

1
2
1n + 1     // TypeError: Cannot mix BigInt and other types
1n + "1"   // "11"

The first line throws an error because no one has written that combination before - there’s nothing to break. The second one still concatenates because the concatenation rule is a quarter of a century older.

New types throw an error instead of being silently converted. But when there’s a conflict with a rule that’s been around since the beginning of JavaScript, the old rule wins hands down!