BFE.dev solution for JavaScript Quiz
11. Implicit Coercion II

TL;DR

For a binary operator, such as addition operator + and subtraction operator -, the result is decided by following steps

  1. get primitive values of operands (number is preferred by default)
  2. if operator is addition + and one of the primitive values is string
    1. covert both of them to string and do string concatenation
  3. otherwise convert both of them to numeric values and do numeric calculation

To get primitive value of an object.

  1. if Symbol.toPrimitive is defined, use it
  2. if string is preferred, first toString() then valueOf() is tried and return the first primitive value;
  3. if number is preferred, first valueOf() then toString() is tried and return the first primitive value;

(below is the detailed explanation, you can scroll down to the end to see the problem analysis)

Implicit Coercion

For addition operator +, it is used for both numeric addition or string concatenation.

1 + 2 // 3
'1' + '2' // '12'

There is also unary plus +, these are different

For subtraction operator - it is only used for numeric subtraction

1 - 2 // -1

What happens if some unexpected data types are used as operands? Well the operands are converted to primitive values implicitly.

Besides above 2 operators, implicit coercion takes places in many other situations. How it works exactly is written in the ECMAScript Spec.

ApplyStringOrNumericBinaryOperator() in ECMAScript Spec

According to the spec of addition operator +, the core is ApplyStringOrNumericBinaryOperator, it actually also applies to other binary operator as well, including subtraction operator -.

Let's take a look at the spec.

  1. If opText is +, then
  2. Let lprim be ? ToPrimitive(lval).
  3. Let rprim be ? ToPrimitive(rval).
  4. If lprim is a String or rprim is a String, then
    1. Let lstr be ? ToString(lprim).
    2. Let rstr be ? ToString(rprim).
    3. Return the string-concatenation of lstr and rstr.
  5. Set lval to lprim.
  6. Set rval to rprim.
  7. NOTE: At this point, it must be a numeric operation.
  8. Let lnum be ? ToNumeric(lval).
  9. Let rnum be ? ToNumeric(rval).
  10. If Type(lnum) is different from Type(rnum), throw a TypeError exception.
  11. If lnum is a BigInt, then
  12. If opText is **, return ? BigInt::exponentiate(lnum, *rnum*).
  13. If opText is /, return ? BigInt::divide(lnum, rnum).
  14. If opText is %, return ? BigInt::remainder(lnum, rnum).
  15. If opText is >>>, return ? BigInt::unsignedRightShift(lnum, rnum).
  16. Let operation be the abstract operation associated with opText and Type(lnum) in the following table:
opText Type(lnum) operation
** Number Number::exponentiate
* Number Number::multiply
* BigInt BigInt::multiply
/ Number Number::divide
% Number Number::remainder
+ Number Number::add
+ BigInt BigInt::add
- Number Number::subtract
- BigInt BigInt::subtract
<< Number Number::leftShift
<< BigInt BigInt::leftShift
>> Number Number::signedRightShift
>> BigInt BigInt::signedRightShift
>>> Number Number::unsignedRightShift
& Number Number::bitwiseAND
& BigInt BigInt::bitwiseAND
^ Number Number::bitwiseXOR
^ BigInt BigInt::bitwiseXOR
| Number Number::bitwiseOR
| BigInt BigInt::bitwiseOR
  1. Return operation(lnum, rnum).

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. Exotic objects may handle the absence of a hint in some other manner.

Looks intimidating but don't be, basically it could be summarized to following rules.

  1. get primitive values of operands (number is preferred by default)
  2. if operator is addition + and one of the primitive values is string
    1. covert both of them to string and do string concatenation
  3. otherwise convert both of them to numeric values and do numeric calculation

Also the note is important - "no hint is provided to the ToPrimitive() call", let's see what it means.

ToPrimitive() in ECMAScript Spec

This defines how we get primitive values from any data types, according to the ECMAScript spec:

  1. If input is an Object, then
  2. Let exoticToPrim be ? GetMethod(input, @@toPrimitive).
  3. If exoticToPrim is not undefined, then
    1. If preferredType is not present, let hint be "default".
    2. Else if preferredType is string, let hint be "string".
    3. Else,
      1. Assert: preferredType is number.
      2. Let hint be "number".
    4. Let result be ? Call(exoticToPrim, input, « hint »).
    5. If result is not an Object, return result.
    6. Throw a TypeError exception.
  4. If preferredType is not present, let preferredType be number.
  5. Return ? OrdinaryToPrimitive(input, preferredType).
  6. Return input.

Here it says, if Symbol.toPrimitive is defined in the object, then the method will be used in ToPrimitive(). This is pretty obvious, below is an example.

const obj =
  {
    [Symbol.toPrimitive](hint) {
      if (hint === 'number') {
        return 100
      }
      return 'obj'
    }
  } 

+ obj // 100

If Symbol.toPrimitive is not defined, the default OrdinaryToPrimitive() is used.

OrdinaryToPrimitive() in ECMAScript spec

This is the default process before Symbol.toPrimitive is a thing. According to the ECMAScript spec:

  1. If hint is string, then
  2. Let methodNames be « "toString", "valueOf" ».
  3. Else,
  4. Let methodNames be « "valueOf", "toString" ».
  5. For each element name of methodNames, do
  6. Let method be ? Get(O, name).
  7. If IsCallable(method) is true, then
  8. Let result be ? Call(method, O).
  9. If result is not an Object, return result.
  10. Throw a TypeError exception.

What it says is basically

  1. if needs a string, first toString() then valueOf() is tried and return the first primitive value;
  2. if needs a number, first valueOf() then toString() is tried and return the first primitive value;

The order is important, Now it's time to take a look at our problem.

Problem Analysis

We can see the key is the primitive values(numeric values preferred) of the operands. In the problem, we have Array and object literals to consider.

For Array

  1. there is no Symbol.toPrimitive defined, continue.
  2. Array inherits Object.prototype.valueOf() is tried, but it just returns the array itself, not a primitive value, continue
  3. Different from Object.prototype.toString(), Array has its own Array.prototype.toString()
    1. we can see from the spec that Array.prototype.join() is used here.
    2. in Array.prototype.join(), it tries to convert all elements to string, which recursively calls Array.prototype.toString().

For Object literal

  1. there is no Symbol.toPrimitive defined, continue.
  2. Object.prototype.valueOf() is tried, but it just returns the object itself, not a primitive value, continue
  3. Object.prototype.toString() is tried
    1. from the spec, we can see string '[object Object]' is returned.

So the problem becomes clear to us now.

Keep in mind that string concatenation only applies when operator is addition + and one of the operands is string

expression primitive val numeric calc or string concat primitive val again result
[] + [] '' + '' string concat '' + '' ''
[] + 1 '' + 1 string concat '' + 1 '1'
[[]] + 1 '' + 1 string concat '' + 1 '1'
[[1]] + 1 '1' + 1 string concat '1' + '1' '11'
[[[[2]]]] + 1 '2' + 1 string concat '2' + '1' '21'
[] - 1 '' - 1 numeric 0 - 1 -1
[[]] - 1 '' - 1 numeric 0 - 1 -1
[[1]] - 1 '1' - 1 numeric 1 - 1 0
[[[[2]]]] - 1 '2' - 1 numeric 2 - 1 1
[] + {} '' + '[object Object]' string concat '' + '[object Object]' '[object Object]'
{} + {} '[object Object]' + '[object Object]' string concat '[object Object]' + '[object Object]' '[object Object][object Object]'
{} - {} '[object Object]' - '[object Object]' numeric NaN - NaN NaN
You might also be able to find a solution fromcommunity posts or fromAI solution.