DEV Community

Hodeem
Hodeem

Posted on

How does it add up?

Introduction

console.log(1 + {}) //"What is the result of the following expression?"
Enter fullscreen mode Exit fullscreen mode

If you've ever seen one of those trick questions and felt lost, you're not alone. I wanted to develop a set of tips, grounded in the ECMAScript spec, to help me predict the outcome of common additive expressions like these. My research led me to write this blog post, so hopefully after reading this, you can answer the question above and others like it.

When faced with an expression like the one presented above, it helps to split the task into two steps:

  1. Predicting the type, and
  2. Predicting the value.

Since predicting the type is more than half the battle, this blog post will focus primarily on step 1 and while covering some common coercions to help with step 2.

Disclaimer

This blog post will only deal with additive expressions with two unmodified operands. Since most of the expressions in these interview-style questions contain mixed types, those will be the focus of this article. For example 1 + "a" as opposed to 1 + 2.

This blog post will not cover all possible combinations and exceptional cases.

The String Gate

The ECMAScript spec extensively outlines how such additive expressions are evaluated, but you can get a solid understanding by analyzing the function ApplyStringOrNumericBinaryOperator.

ApplyStringOrNumericBinaryOperator ( lVal, opText, rVal )
Enter fullscreen mode Exit fullscreen mode

In the expression 1 + {}, lVal would be 1, opText would be + and rVal would be {}. Here's the beginning of the algorithm:

1. If opText is +, then
   a. Let `lPrim` be ? `ToPrimitive(lVal)`.
   b. Let `rPrim` be ? `ToPrimitive(rVal)`.
   c. If `lPrim` is a String or `rPrim` is a String, then
      i. Let `lStr` be ? `ToString(lPrim)`.
      ii. Let `rStr` be ? `ToString(rPrim)`.
      iii. Return the string-concatenation of `lStr` and `rStr`.
Enter fullscreen mode Exit fullscreen mode

It might feel like a lot to digest, but I want to highlight step 1c., which I dub the 'String Gate'.

c. If `lPrim` is a String or `rPrim` is a String, 
then ... Return the string-concatenation of `lStr` and `rStr`.
Enter fullscreen mode Exit fullscreen mode

ToPrimitive is a function that returns primitives as-is and attempts to coerce objects to primitives. So, this brings us to the first tip:

If an additive expression contains a string, it will evaluate to a string.

Here are some examples:

// At least one operand is a string
console.log("1" + 2); // "12"
console.log("1" + false); // "1false"
console.log("1" + {}); // "1[object Object]"
Enter fullscreen mode Exit fullscreen mode

We can predict what operands will be coerced into when an expression contains a string by looking at what ToString does. For our purposes, here are the conversions:

Data Type Outcome
String Returns as-is
Symbol Throws a TypeError
undefined Returns 'undefined'
null Returns 'null'
true Returns 'true'
false Returns 'false'
Number Returns Number::toString(argument, 10)
BigInt Returns BigInt::toString(argument, 10)
Object Returns ToPrimitive(argument, STRING)**

** We'll touch on ToPrimitive in a bit.

console.log("John " + 1n); // "John 1"
console.log("John " + undefined); // "John undefined"
Enter fullscreen mode Exit fullscreen mode

Strings are caught by the 'String Gate'.

Dates

To prepare for the next tip, let's start with this note for the ApplyStringOrNumericBinaryOperator function:

1. If `opText` is +, then
   a. Let `lPrim` be ? `ToPrimitive(lVal)`.
   b. Let `rPrim` be ? `ToPrimitive(rVal)`.
...
Note 1
**No hint is provided in the calls to `ToPrimitive` in steps 1.a and 1.b.** 
All standard objects except Dates handle the absence of a hint 
as if NUMBER were given; 
Dates handle the absence of a hint as if STRING were given
Enter fullscreen mode Exit fullscreen mode

To make sense of this note, we need to review the function at the heart of ToPrimitive, which is OrdinaryToPrimitive.

OrdinaryToPrimitive ( obj, hint )
Enter fullscreen mode Exit fullscreen mode

In this function, there are two method options: "toString" and "valueOf". The order of execution will depend on the value of hint. If hint is "string", then "toString" will be called first. If not, then "valueOf" will be called first.

If the first method's result is a primitive, then that value will be returned from OrdinaryToPrimitive. If not, then the second method is called, and its value is checked as well. If neither returns a primitive, then a TypeError exception is thrown.

Here's an example of what happens if hint is "number" for an array literal:

console.log([].valueOf());  // [] not a primitive, call next method ❌
console.log([].toString()); // "" primitive, return this ✔️
Enter fullscreen mode Exit fullscreen mode

Now let's look at how the ToPrimitive function connects ApplyStringOrNumericBinaryOperator to OrdinaryToPrimitive.

ToPrimitive ( input [ , preferredType ] )
Enter fullscreen mode Exit fullscreen mode

where preferredType is optional and can be either STRING or NUMBER. ToPrimitive can be simplified to the following:

  1. If input is not an object, return it as-is.

  2. If input has a Symbol.toPrimitive method, then invoke it with
    hint as its argument and return the result.

  3. If input doesn't have a Symbol.toPrimitive method, then return
    OrdinaryToPrimitive(input, preferredType).

Here's how preferredType maps to hint in step 3.

preferredType hint
undefined "number"
STRING "string"
NUMBER "number"

There are only two built-in JavaScript objects that have a Symbol.toPrimitive method by default. They are Symbol and Date.

Remember, as Note 1 said, no hint is provided to ToPrimitive and Dates handle the absence of a hint as if string were given. This is because when no hint is provided, Date.prototype[Symbol.toPrimitive](hint) internally passes hint = "string" to OrdinaryToPrimitive.

This means that "toString" is the first method called, and by default Date.prototype.toString returns a human-readable string representing the date and time in the local time zone.

console.log((new Date()).toString()); // "Mon May 11 2026 12:26:17 GMT+0000 (Coordinated Universal Time)"
Enter fullscreen mode Exit fullscreen mode

This returned string means there's no need to call the second method "valueOf", but it also means that the expression will be caught by the 'String Gate'.

Here is the corresponding tip:

If an additive expression contains a Date, it will evaluate to a string.

console.log(true + new Date()); // "trueMon May 11 2026 12:28:00 GMT+0000 (Coordinated Universal Time)"
console.log(1 + new Date()); // "1Mon May 11 2026 12:28:29 GMT+0000 (Coordinated Universal Time)"
Enter fullscreen mode Exit fullscreen mode

Dates are caught by the 'String Gate'.

Common objects

Let's look at the other objects. Those that don't have a Symbol.toPrimitive method.

Remember that no hint is passed to ToPrimitive and consequently, none is passed to OrdinaryToPrimitive. (All standard objects except Dates handle the absence of a hint as if NUMBER were given;). This means that "valueOf" will be executed first in OrdinaryToPrimitive.

Let's look at what happens when you invoke "valueOf" on two common groups: Object and array literals ({} or []) and primitive wrapper objects (like Number() or String()).

Object and array literals return themselves when their "valueOf" method is invoked, which doesn't pass the primitive check. So, that leaves "toString" which, as you might have guessed, returns a string:

console.log([1,2].valueOf()); // [1,2] - not a primitive, call next method ❌
console.log([1,2].toString()); // "1,2" - primitive, return this ✔️ 

console.log({}.valueOf()); // {} - not a primitive, call next method ❌
console.log({}.toString()); // "[object Object]" - primitive, return this ✔️
Enter fullscreen mode Exit fullscreen mode

Here's the corresponding tip:

If an additive expression contains an object or array literal, it will evaluate to a string.

Some examples:

console.log(1 + [2,3]); // "12,3"
console.log([1,2] + [2,3]); // "1,22,3"
console.log({a: 1} + {b: 2}); // "[object Object][object Object]"
Enter fullscreen mode Exit fullscreen mode

Object and array literals are caught by the 'String Gate'.

Primitive wrapper objects, on the other hand, unwrap when the "valueOf" method is invoked. Most returned values pass the primitive check and are returned by OrdinaryToPrimitive. No need to call "toString":

console.log((new String("4")).valueOf()); // "4" - primitive, return this ✔️
console.log((new Number(5)).valueOf());  // 5 - primitive, return this ✔️
Enter fullscreen mode Exit fullscreen mode

This means that with the exception of String, most primitive wrapper objects can pass the 'String Gate'.

I hope it has become clear that most of the common combinations of types don't make it past the 'String Gate'. Let's go beyond the 'String Gate' now.

Twin Gate

Here's some more of the ApplyStringOrNumericBinaryOperator function:

1. If `opText` is +, then
   a. Let `lPrim` be ? `ToPrimitive(lVal)`.
   b. Let `rPrim` be ? `ToPrimitive(rVal)`.
   c. If `lPrim` is a String or `rPrim` is a String, then
      i. Let `lStr` be ? `ToString(lPrim)`.
      ii. Let `rStr` be ? `ToString(rPrim)`.
      iii. Return the string-concatenation of `lStr` and `rStr`.
   d. Set `lVal` to `lPrim`.
   e. Set `rVal` to `rPrim`.
2. NOTE: At this point, it must be a numeric operation.
3. Let `lNum` be ? `ToNumeric(lVal)`.
4. Let `rNum` be ? `ToNumeric(rVal)`.
5. If `SameType(lNum, rNum)` is false, throw a TypeError exception.
Enter fullscreen mode Exit fullscreen mode

If 1c was the 'String Gate', then step 5 is the 'Twin Gate'. First, I want to highlight the assertion at step 2, which lets us know that from this point forward, only instances of Number and BigInt are acceptable. And the 'Twin Gate' tells us that both operands must evaluate to the same type for evaluation to proceed.

So that gives us our last tip for reasoning about types.

An additive expression that contains numeric operands with different types will throw a TypeError.

console.log(1 + 1n); // ERROR!
Enter fullscreen mode Exit fullscreen mode

If you haven't realized by now, JavaScript coercion can be tricky. To predict the result of additive expressions, it helps to take a look at ToNumeric.

1. Let `primValue` be ? `ToPrimitive(value, number)`.
2. If `primValue` is a `BigInt`, return `primValue`.
3. Return ? `ToNumber(primValue)`.
Enter fullscreen mode Exit fullscreen mode

So, Numbers and BigInts get returned as-is. Everything else goes to ToNumber. For our purposes, here's what ToNumber does:

Data Type Outcome
Number Returns as-is
Symbol or BigInt Throws a TypeError
undefined Returns NaN
null or false Returns 0
true Returns 1
String Returns parsed Number or NaN
Object Returns ToPrimitive(argument, NUMBER)

Building on the previous tips allows us to predict the outcome of some more combinations:

console.log(1 + undefined); // NaN
console.log(true + true); // 2
console.log(true + false); // 1
console.log(2 + null); // 2
Enter fullscreen mode Exit fullscreen mode

Summary

Here are the tips once more:

If an additive expression contains a string, it will evaluate to a string.

If an additive expression contains a Date, it will evaluate to a string.

If an additive expression contains an object or array literal, it will evaluate to a string.

An additive expression that contains numeric operands with different types will throw a TypeError.

With these tips and some practice, I hope you'll be able to predict the outcome of additive expressions more confidently. Give this one a try:

console.log(1 + {}) //"What is the result of the following expression?"
Enter fullscreen mode Exit fullscreen mode

Thanks for reading.

Sources

ECMAScript Language Specification

Top comments (0)