JAVASCRIPT / VALUES, TYPES, AND COERCION
toString, valueOf, and object-to-primitive hints
Control how your objects convert to primitives with toString, valueOf, and Symbol.toPrimitive, and predict which one a given operator calls.
What you will learn
- Predict which hint an operator sends: string, number, or default
- Order the methods right: valueOf first for number and default, toString for string
- Write Symbol.toPrimitive(hint) to cover all three conversions in one method
- Diagnose [object Object] output and Cannot convert object to primitive errors
Understanding toString, valueOf, and object-to-primitive hints
Whenever an object appears where the language needs a primitive, in a template literal, beside a multiplication sign, next to a plus, the engine runs one internal routine called ToPrimitive and passes it a hint naming the kind of value the operation would prefer. There are exactly three hints: string for String(obj), template literals, and property-key positions; number for -, *, /, %, unary plus, the relational operators, and Number(obj); and default for binary plus and for == against a primitive. The hint is a preference about ordering, not a promise about the result type.
Under the string hint the engine calls toString first and falls back to valueOf. Under the number and default hints the order is reversed: valueOf, then toString. A method's result is accepted only if it is already a primitive, so a method that hands back an object is skipped as though it did not exist, and if both are skipped the conversion throws a TypeError. That rule is the whole explanation for [object Object]: Object.prototype.valueOf returns the object itself, which is never a primitive, so even a number-hinted operation ends up at Object.prototype.toString.
A single method can cover all three cases. Define [Symbol.toPrimitive](hint) and the engine calls it with the hint as a string and ignores toString and valueOf entirely; the value it returns must be a primitive, because there is no fallback. Whichever route runs, ToPrimitive only produces some primitive, and then the operator applies its own rules to that primitive. So valueOf returning the string '10' is perfectly acceptable, and obj * 2 gives 20 while obj + 5 gives '105', because multiplication converts that string to a number and plus sees a string and concatenates.
const money = {
amount: 42,
toString() {
return 'USD ' + this.amount;
},
valueOf() {
return this.amount;
}
};
console.log(`${money}`); // string hint
console.log(money * 2); // number hint
console.log(money + 1); // default hint
console.log(money + '!'); // default hint, valueOf still wins
console.log(money > 40); // number hintEvery object-to-primitive conversion runs the same algorithm: an operator picks a hint, and the hint decides whether valueOf or toString is tried first.
Worked examples
One method, three hints
Symbol.toPrimitive receives the hint as an argument and takes precedence over the older methods.
const temp = {
celsius: 21,
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.celsius;
if (hint === 'string') return this.celsius + 'C';
return 'temp(' + this.celsius + ')';
},
toString() {
return 'never called';
}
};
console.log(+temp);
console.log(`${temp}`);
console.log(temp + '!');Example explained
Line 1Unary plus needs a number, so the hint argument is 'number' and the method returns 21.
Line 2The template literal sends 'string', producing '21C' from the same object.
Line 3temp + '!' sends 'default', the hint reserved for binary plus and ==, so the third branch runs.
Line 4toString is defined but never reached, because Symbol.toPrimitive replaces both legacy methods.
Why plain objects print [object Object]
Shows the inherited valueOf failing to produce a primitive so the conversion falls through to toString.
const plain = {};
console.log(String(plain));
console.log(plain.valueOf() === plain);
console.log([1, 2] + [3]);
console.log([] + {});Example explained
Line 1String(plain) uses the string hint, so Object.prototype.toString runs first and returns '[object Object]'.
Line 2plain.valueOf() === plain proves the inherited valueOf returns the object itself, which ToPrimitive rejects as non-primitive.
Line 3Arrays inherit that same valueOf, so [1, 2] + [3] falls through to Array.prototype.toString, which joins with commas: '1,2' + '3'.
Line 4[] + {} is therefore '' + '[object Object]'; nothing numeric ever took part in the operation.
Non-primitive returns and returned types
Demonstrates the TypeError when both methods fail, and that the type valueOf returns still steers the operator.
const broken = {
toString() { return {}; },
valueOf() { return {}; }
};
try {
console.log(broken + '');
} catch (err) {
console.log(err.name);
}
const loose = {
valueOf() { return '10'; }
};
console.log(loose * 2);
console.log(loose + 5);Example explained
Line 1broken + '' tries valueOf, gets an object, tries toString, gets an object, then throws because there is no third candidate.
Line 2loose * 2 accepts the string '10' as a legitimate primitive result, and multiplication then applies its own numeric conversion to reach 20.
Line 3loose + 5 takes the identical primitive '10', but plus sees a string operand and concatenates, giving '105'.
Line 4Both results come from one valueOf, so the type you return matters as much as the value.
Important notes
Date is the built-in exception: its Symbol.toPrimitive treats the default hint as string, which is why d + 1 concatenates a date string while d - 1 subtracts milliseconds.
In browser devtools and Node, console.log(obj) prints an inspected structure and does not call your conversion methods, so test with String(obj), a template literal, or +obj.
Common mistakes
Writing a nicely formatted toString and then building strings with 'Total: ' + obj: binary plus sends the default hint, so valueOf runs first and the log shows a bare number while the formatting silently never appears.
Forgetting the return in valueOf, or missing a branch: the method yields undefined, which is a valid primitive, so ToPrimitive accepts it instead of falling back to toString and every arithmetic result becomes NaN.
Expecting if (obj) or Boolean(obj) to consult valueOf: boolean conversion never calls ToPrimitive, so an object whose valueOf returns 0 is still truthy and the branch runs anyway.
Try it yourself
Change, predict, then run
In a browser console, write a Duration object with a minutes property whose toString returns text like '1h 30m' and whose valueOf returns the raw minute count, then evaluate String(d), d + 0, d > 60 and d + ' total' and explain each result. Now replace both methods with a single [Symbol.toPrimitive](hint) that reproduces all four results.
Open the JavaScript workspaceCheck your understanding
An object defines toString returning 'box' and valueOf returning 7. What does 'total: ' + box evaluate to, and why?
- 'total: 7', because binary plus sends the default hint and the default order tries valueOf before toString
- 'total: box', because the string operand on the left switches the conversion to the string hint
- 'total: [object Object]', because Object.prototype.toString takes priority during concatenation
- A TypeError, because the object defines two conflicting conversion methods
Show answer
Binary plus always sends the default hint, and default means valueOf first, so the object becomes 7 and the concatenation produces 'total: 7'. The second option reverses the order of events: plus only chooses between concatenation and addition after ToPrimitive has finished, so a string on the other side cannot change which hint was sent or which method was consulted.