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
- get primitive values of operands (number is preferred by default)
- if operator is addition
+
and one of the primitive values is string- covert both of them to string and do string concatenation
- otherwise convert both of them to numeric values and do numeric calculation
To get primitive value of an object.
- if Symbol.toPrimitive is defined, use it
- if string is preferred, first
toString()
thenvalueOf()
is tried and return the first primitive value; - if number is preferred, first
valueOf()
thentoString()
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.
- If opText is +, then
- Let lprim be ? ToPrimitive(lval).
- Let rprim be ? ToPrimitive(rval).
- If lprim is a String or rprim is a String, then
- Let lstr be ? ToString(lprim).
- Let rstr be ? ToString(rprim).
- Return the string-concatenation of lstr and rstr.
- Set lval to lprim.
- Set rval to rprim.
- NOTE: At this point, it must be a numeric operation.
- Let lnum be ? ToNumeric(lval).
- Let rnum be ? ToNumeric(rval).
- If Type(lnum) is different from Type(rnum), throw a TypeError exception.
- If lnum is a BigInt, then
- If opText is **, return ? BigInt::exponentiate(lnum, *rnum*).
- If opText is /, return ? BigInt::divide(lnum, rnum).
- If opText is %, return ? BigInt::remainder(lnum, rnum).
- If opText is >>>, return ? BigInt::unsignedRightShift(lnum, rnum).
- 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
- 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.
- get primitive values of operands (number is preferred by default)
- if operator is addition
+
and one of the primitive values is string- covert both of them to string and do string concatenation
- 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:
- If input is an Object, then
- Let exoticToPrim be ? GetMethod(input, @@toPrimitive).
- If exoticToPrim is not undefined, then
- If preferredType is not present, let hint be "default".
- Else if preferredType is string, let hint be "string".
- Else,
- Assert: preferredType is number.
- Let hint be "number".
- Let result be ? Call(exoticToPrim, input, « hint »).
- If result is not an Object, return result.
- Throw a TypeError exception.
- If preferredType is not present, let preferredType be number.
- Return ? OrdinaryToPrimitive(input, preferredType).
- 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:
- If hint is string, then
- Let methodNames be « "toString", "valueOf" ».
- Else,
- Let methodNames be « "valueOf", "toString" ».
- For each element name of methodNames, do
- Let method be ? Get(O, name).
- If IsCallable(method) is true, then
- Let result be ? Call(method, O).
- If result is not an Object, return result.
- Throw a TypeError exception.
What it says is basically
- if needs a string, first
toString()
thenvalueOf()
is tried and return the first primitive value; - if needs a number, first
valueOf()
thentoString()
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
- there is no Symbol.toPrimitive defined, continue.
- Array inherits Object.prototype.valueOf() is tried, but it just returns the array itself, not a primitive value, continue
- Different from Object.prototype.toString(), Array has its own Array.prototype.toString()
- we can see from the spec that
Array.prototype.join()
is used here. - in Array.prototype.join(), it tries to convert all elements to string, which recursively calls Array.prototype.toString().
- we can see from the spec that
For Object literal
- there is no Symbol.toPrimitive defined, continue.
- Object.prototype.valueOf() is tried, but it just returns the object itself, not a primitive value, continue
- Object.prototype.toString() is tried
- from the spec, we can see string
'[object Object]'
is returned.
- from the spec, we can see string
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 |