ToPrimitive: The Complete Object-to-Primitive Conversion Algorithm
[] + [] equals the empty string "", while {} + [] equals 0 โ two expressions with opposite results that both follow the same algorithm: ToPrimitive.
๐น Level 1 ยท What You Need to Know
When JS Converts Objects to Primitives
The following scenarios trigger implicit object-to-primitive conversion:
// 1. Template literals (string hint)
const obj = { toString() { return "hello"; } };
`${obj}` // "hello" โ calls toString()
// 2. + operator (default hint, mostly same as number)
obj + "" // depends on toString/valueOf
[] + 1 // "1" (array converts to string, then concatenates)
// 3. Comparison operators (default hint)
obj < 2 // converts obj to number or string first
// 4. if condition (ToBoolean โ NOT ToPrimitive)
if (obj) { } // all objects are truthy, ToPrimitive is NOT called
// 5. Arithmetic operators (number hint)
obj * 2 // triggers number hint ToPrimitive
obj - 1
obj / 2
Important: if conditions do NOT trigger ToPrimitive โ they use ToBoolean. All objects (including new Boolean(false)) are truthy.
Custom Conversion with Symbol.toPrimitive
Symbol.toPrimitive is the highest-priority mechanism for controlling object conversion:
const money = {
amount: 100,
currency: "USD",
[Symbol.toPrimitive](hint) {
// hint is one of "number", "string", or "default"
switch(hint) {
case "number":
return this.amount; // return number for arithmetic
case "string":
return `${this.amount} ${this.currency}`; // formatted for template literals
default:
return this.amount; // for + and == operations
}
}
};
console.log(+money); // 100 (number hint)
console.log(`${money}`); // "100 USD" (string hint)
console.log(money + ""); // "100" (default โ number โ to string)
console.log(money > 50); // true (number hint)
When valueOf() and toString() Are Called
Without Symbol.toPrimitive, JS calls these methods in priority order:
const obj = {
valueOf() {
console.log("valueOf called");
return 42;
},
toString() {
console.log("toString called");
return "obj";
}
};
// number hint: valueOf first, toString second
+obj; // "valueOf called" โ 42
obj * 2; // "valueOf called" โ 84
obj - 1; // "valueOf called" โ 41
// string hint: toString first, valueOf second
`${obj}`; // "toString called" โ "obj"
String(obj); // "toString called" โ "obj"
// If valueOf returns a non-primitive, toString is tried next
const obj2 = {
valueOf() { return {}; }, // returns an object (not primitive)
toString() { return "fallback"; }
};
+obj2; // "fallback" (valueOf returned object, so toString is called)
Date is special: Date overrides Symbol.toPrimitive to treat the "default" hint as "string" (not "number"):
const date = new Date("2024-01-01");
date + "" // "Mon Jan 01 2024 ..." โ string concatenation
date + 1 // "Mon Jan 01 2024 ...1" โ Date prefers string path
date * 1 // 1704067200000 โ * forces number hint, uses numeric path
๐ธ Level 2 ยท How It Actually Runs
ToPrimitive Complete Decision Tree
ToPrimitive(input, preferredType?)
โ
โโ input is a primitive (Undefined/Null/Boolean/Number/String/Symbol/BigInt)?
โ โโ YES โ return input immediately (already primitive, no conversion)
โ
โโ input is an Object?
โ
โโ Does input[Symbol.toPrimitive] exist?
โ โโ YES โ call input[Symbol.toPrimitive](hint)
โ โ hint = "number" | "string" | "default"
โ โ โโ return value is primitive โ return it
โ โ โโ return value is object โ throw TypeError
โ โ
โ โโ NO โ enter OrdinaryToPrimitive(input, hint)
โ
โโ OrdinaryToPrimitive(O, hint)
โ
โโ hint is "string"?
โ โโ methodNames = ["toString", "valueOf"]
โ
โโ hint is "number" or "default"?
โ โโ methodNames = ["valueOf", "toString"]
โ
โโ iterate methodNames:
โโ call O[methodName]()
โโ result is primitive โ return it
โโ result is object โ try next method
all methods return objects โ throw TypeError
Where hints Come From
Different operators trigger different hints:
hint = "string":
- Template literals `${obj}`
- String(obj) (explicit conversion)
- Object property key access (key converted to string in obj[key])
hint = "number":
- Unary + operator (+obj)
- Unary - operator (-obj)
- Arithmetic operators (* / - %)
- Comparison operators (< > <= >=)
- Number(obj) (explicit conversion)
hint = "default":
- Binary + operator (obj + something)
- == comparison (obj == primitive)
- When preferredType is unspecified
Most built-in objects:
"default" behaves identically to "number"
Date is the exception:
"default" behaves identically to "string"
ToPrimitive Behavior of Built-in Objects
| Object type | hint=number | hint=string | hint=default |
|---|---|---|---|
| Array | valueOf() โ array โ toString() โ comma-separated |
toString() โ comma-separated |
same as number |
| Date | valueOf() โ timestamp (ms) |
toString() โ date string |
same as string |
| RegExp | valueOf() โ RegExp object โ toString() โ /pattern/flags |
toString() โ /pattern/flags |
same as number |
| Plain Object | valueOf() โ object โ toString() โ [object Object] |
toString() โ [object Object] |
same as number |
// Array ToPrimitive examples
const arr = [1, 2, 3];
+arr // NaN ("1,2,3" converted to number is NaN)
`${arr}` // "1,2,3"
arr + "" // "1,2,3"
// Single-element array special case
+[42] // 42 ("42" to number is 42)
+[] // 0 ("" to number is 0)
// Plain object
const obj = {};
+obj // NaN ("[object Object]" to number is NaN)
obj + "" // "[object Object]"
`${obj}` // "[object Object]"
๐บ Level 3 ยท How the Spec Defines It
7.1.1 ToPrimitive(input, preferredType)
Spec text (ECMA-262, Section 7.1.1):
7.1.1 ToPrimitive ( input [ , preferredType ] )
The abstract operation ToPrimitive takes argument input (an ECMAScript language value) and optional argument preferredType (string or number) and returns either a normal completion containing an ECMAScript language value or a throw completion. It converts its input argument to a non-Object type. If an object is capable of converting to more than one primitive type, it may use the optional hint preferredType to favour that type.
- If input is an Object, then a. Let exoticToPrim be ? GetMethod(input, @@toPrimitive). b. If exoticToPrim is not undefined, then i. If preferredType is not present, let hint be "default". ii. Else if preferredType is string, let hint be "string". iii. Else, let hint be "number". iv. Let result be ? Call(exoticToPrim, input, ยซ hint ยป). v. If result is not an Object, return result. vi. Throw a TypeError exception. c. If preferredType is not present, let preferredType be number. d. Return ? OrdinaryToPrimitive(input, preferredType).
- Return input.
7.1.1.1 OrdinaryToPrimitive(O, hint)
7.1.1.1 OrdinaryToPrimitive ( O, hint )
- If hint is string, then a. Let methodNames be ยซ "toString", "valueOf" ยป.
- Else, a. Let methodNames be ยซ "valueOf", "toString" ยป.
- For each element name of methodNames, do a. Let method be ? Get(O, name). b. If IsCallable(method) is true, then i. Let result be ? Call(method, O). ii. If result is not an Object, return result.
- Throw a TypeError exception.
Key insights:
-
In the spec,
@@toPrimitiverefers toSymbol.toPrimitive. The@@notation represents Well-Known Symbols in spec text. -
Notice step 1.b.vi: if
Symbol.toPrimitivereturns an object, a TypeError is thrown immediately โ no fallback tovalueOfortoString. -
Step 1.c: if there's no
Symbol.toPrimitiveandpreferredTypeis unspecified, it defaults tonumber. This is why "default" hint behavior resembles "number". -
Step 4: if both
valueOfandtoStringreturn objects, a TypeError is ultimately thrown.
Date.prototype[Symbol.toPrimitive] โ The Special Case
Date overrides Symbol.toPrimitive. From spec Section 21.4.4.44:
21.4.4.44 Date.prototype [ @@toPrimitive ] ( hint )
- Let O be the this value.
- Perform ? RequireInternalSlot(O, [[DateValue]]).
- If hint is "string" or hint is "default", then a. Let tryFirst be string.
- Else if hint is "number", then a. Let tryFirst be number.
- Else, throw a TypeError exception.
- Return ? OrdinaryToPrimitive(O, tryFirst).
The critical difference: Date takes the "string" path when hint is "default" (step 3), while ordinary objects take the "number" path. This is why new Date() + 1 produces string concatenation.
๐ Level 4 ยท Edge Cases and Traps
Trap 1: Complete Derivation of [] + [] = ""
Expression: [] + []
Step 1: The + operator triggers ToPrimitive on both operands (hint = "default")
Step 2: ToPrimitive([], "default") for the left operand
- [] has no Symbol.toPrimitive
- "default" hint โ treated as "number" โ call order: valueOf first, toString second
- [].valueOf() โ [] (returns the array itself โ an object, not primitive)
- [].toString() โ "" (empty array toString is the empty string)
- Result: ""
Step 3: ToPrimitive([], "default") for the right operand
- Same as above, result: ""
Step 4: + operator with "" and ""
- One operand is a string โ string concatenation
- "" + "" = ""
Final result: ""
Trap 2: The Parsing Difference Between [] + {} and {} + []
This is one of JavaScript's most notorious "weird behaviors":
[] + {} // "[object Object]"
{} + [] // 0 (or "[object Object]" in some environments)
Why the different results?
[] + {} as an expression:
ToPrimitive([]) โ ""
ToPrimitive({}) โ "[object Object]"
"" + "[object Object]" = "[object Object]"
{} + [] as a statement (typed directly in the console):
{} is parsed as an empty block statement
+ [] is unary + applied to []
ToNumber([]) โ ToNumber("") โ 0
Result: 0
{} + [] as an expression (inside assignment or parentheses):
({} + []) โ "[object Object]" (correctly parsed as object literal)
Verification:
// Forced to evaluate as expression
console.log({} + []); // "[object Object]" (wrapped in function call)
let x = {} + []; // "[object Object]" (right-hand side of assignment)
// Statement context (only when typed directly into a REPL)
// {} + [] in Node.js REPL โ 0
Trap 3: Why new Date() + 1 Takes the String Path
Derivation of new Date() + 1:
Step 1: + operator triggers ToPrimitive on new Date() (hint = "default")
Step 2: Date has custom Symbol.toPrimitive
Date[Symbol.toPrimitive]("default")
โ hint is "default", Date special-cases this: takes "string" path
โ OrdinaryToPrimitive(date, "string")
โ call order: toString first
โ date.toString() โ "Tue Jan 01 2025 ..." (date string)
Step 3: 1 needs no conversion (already primitive)
Step 4: + operator, one operand is a string โ string concatenation
"Tue Jan 01 2025 ..." + "1" = "Tue Jan 01 2025 ...1"
To get numeric timestamp addition instead:
// โ
Force number hint
+new Date() + 1 // 1704067200001 (timestamp + 1)
new Date().valueOf() + 1 // 1704067200001
Date.now() + 1 // 1704067200001 (recommended)
Trap 4: Returning an Object from Symbol.toPrimitive Throws TypeError
This is a spec behavior that is easy to miss during debugging:
const obj = {
[Symbol.toPrimitive](hint) {
return {}; // โ returns an object!
}
};
// Any operation that triggers ToPrimitive will throw
`${obj}` // TypeError: Cannot convert object to primitive value
+obj // TypeError: Cannot convert object to primitive value
obj + "" // TypeError: Cannot convert object to primitive value
Compare with an object that has no Symbol.toPrimitive but both valueOf and toString return objects:
const obj2 = {
valueOf() { return {}; },
toString() { return {}; },
};
// Also throws TypeError, slightly different message path
+obj2 // TypeError: Cannot convert object to primitive value
A real-world trap:
// โ Wrong: Symbol.toPrimitive must return a primitive
class Price {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
// Forgot that it must return a primitive
if (hint === 'number') return { value: this.amount }; // BUG!
return this.toString();
}
}
// โ
Correct
class Price {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount; // primitive number
if (hint === 'string') return `${this.amount} ${this.currency}`;
return this.amount; // default
}
}
Trap 5: ToPrimitive vs JSON.stringify
JSON.stringify does NOT use ToPrimitive โ it uses the toJSON method:
const obj = {
value: 42,
valueOf() { return 100; }, // called by ToPrimitive
toString() { return "hundred"; }, // ToPrimitive fallback
toJSON() { return { val: this.value }; } // called by JSON.stringify
};
+obj // 100 (ToPrimitive โ valueOf)
`${obj}` // "hundred" (string hint โ toString โ "hundred" is a primitive)
JSON.stringify(obj) // '{"val":42}' (uses toJSON, bypasses ToPrimitive entirely)
Date's toJSON is called during JSON.stringify:
const date = new Date("2024-01-01");
JSON.stringify(date) // '"2024-01-01T00:00:00.000Z"' (calls date.toJSON())
date.toJSON() // "2024-01-01T00:00:00.000Z" (ISO string)
Chapter Summary
-
ToPrimitive has three hint values: "number" (arithmetic), "string" (template literals, String()), "default" (binary + and ==). For most objects, "default" behaves like "number"; Date is the only built-in where "default" follows the "string" path.
-
Symbol.toPrimitive has the highest priority: If an object defines
Symbol.toPrimitive, ToPrimitive calls it directly, completely bypassingvalueOfandtoString. But the returned value must be a primitive โ otherwise a TypeError is thrown. -
OrdinaryToPrimitive's call order depends on hint: number hint calls
valueOffirst, thentoString; string hint callstoStringfirst, thenvalueOf. Understanding this order is the key to deciphering expressions like[] + {}. -
[] + []equals""because: array'svalueOf()returns itself (an object), sotoString()is called next, yielding"". Two empty strings concatenated is still"". -
{} + []is context-sensitive: as a statement,{}is parsed as an empty block and+[]becomes unary plus, yielding0; as an expression,{}is an object literal, yielding"[object Object]". Never rely on this ambiguity โ use parentheses to make your intent explicit.