Object to primitive conversion
Write obj1 + obj2, obj1 - obj2, or alert(obj), and you hand the engine a problem: operators work on primitives, but you gave them objects. What happens next?
JavaScript won’t let you teach an operator new tricks. In some languages — Ruby, C++ — you can overload + so that adding two of your own objects returns a third. That door is closed here. There is no method you can define that makes + return an object.
Instead, when an operator meets an object, the engine quietly converts that object into a primitive, runs the operation on the primitives, and gives you back a primitive. The result of obj1 + obj2 is never another object.
Because of that ceiling, real projects rarely do arithmetic on objects. When it happens by accident, it’s usually a bug, and knowing the conversion rules helps you spot it. There are also legitimate cases — subtracting two Date objects to get a time difference is the classic one — where the conversion is the whole point. Both reasons are why this chapter is worth your time: to diagnose mistakes, and to build the rare object that converts on purpose.
The three conversions
Back in Type Conversions we walked through how primitives turn into numbers, strings, and booleans. We skipped objects then. Now that methods and symbols are on the table, we can fill that gap.
Three facts frame everything below:
- Boolean conversion never runs for objects. Every object is truthy. An empty object, an empty array, even
new Boolean(false)— alltruein a boolean context. There’s nothing to customize, so only string and number conversions remain. - Numeric conversion fires when you do maths on an object — subtraction, comparison with
</>,Number(obj), most math functions. - String conversion fires when the context wants text, most visibly
alert(obj), or using an object as another object’s property key.
anotherObj[obj] = 1
obj1 > obj2
(no conversion)
You control string and number conversion with special methods. Time for the details — there’s no shortcut around them.
Hints
How does the engine pick between string and number conversion? It computes a hint — a label describing the kind of primitive the operation is hoping for. The specification defines three of them.
Here’s the practical shortcut: every built-in object except Date handles "default" exactly like "number". Your own objects should usually do the same. So in day-to-day code, "default" and "number" behave identically — but the spec keeps them separate, and Date uses that separation, so it pays to know all three exist.
| operation | hint |
|---|---|
| alert(obj), String(obj) | “string” |
| +obj, obj - obj, Number(obj) | “number” |
| obj < obj, obj > obj | “number” |
| obj + obj, obj == primitive | “default” |
The algorithm
Whatever the hint, the engine hunts for up to three methods, in this order:
- If
obj[Symbol.toPrimitive](hint)exists, call it. Done — its return value is the result, and no other method is consulted. - Otherwise, if the hint is
"string": tryobj.toString(), then fall back toobj.valueOf(). - Otherwise (hint is
"number"or"default"): tryobj.valueOf(), then fall back toobj.toString().
Symbol.toPrimitive
Start with the modern method. There’s a built-in system symbol, Symbol.toPrimitive, meant to be the property key for a single conversion method that handles every hint:
obj[Symbol.toPrimitive] = function(hint) {
// return a primitive value
// hint is one of "string", "number", "default"
};
Define it, and it wins outright: the engine calls it for all three hints and never looks at toString or valueOf. One method, total control.
Here’s a user that reports itself as a descriptive string in text contexts and as its money in numeric ones:
let user = {
name: "Maya",
money: 1000,
[Symbol.toPrimitive](hint) {
alert(`hint: ${hint}`);
return hint == "string" ? `{name: "${this.name}"}` : this.money;
}
};
// conversions demo:
alert(user); // hint: string -> {name: "Maya"}
alert(+user); // hint: number -> 1000
alert(user + 500); // hint: default -> 1500
Each call arrives with a different hint, and the single method branches on it. In the last line, binary + passes "default", the method returns the number 1000, and 1000 + 500 gives 1500.
Try each operation below. The same single method runs every time — only the hint it receives changes, and that hint decides which branch returns:
toString / valueOf
When Symbol.toPrimitive is absent, the engine falls back to the older pair, toString and valueOf:
- Hint
"string": calltoStringfirst. If it’s missing or returns an object, tryvalueOf. SotoStringhas priority for text. - Hints
"number"and"default": callvalueOffirst. If it’s missing or returns an object, trytoString. SovalueOfhas priority for maths.
These two predate symbols by many years — they’re plain string-named methods, not symbolic keys. They must return a primitive; if one returns an object, the engine ignores it and moves to the other method (no error — that’s the ancient, forgiving behavior).
Every plain object inherits defaults for both:
toString()returns the string"[object Object]".valueOf()returns the object itself.
let user = {name: "Maya"};
alert(user); // [object Object]
alert(user.valueOf() === user); // true
That’s why an un-customized object shows up as [object Object] in an alert. The default valueOf returns the object, which — being an object, not a primitive — gets ignored during conversion. For practical purposes you can pretend the default valueOf isn’t there; it exists only for historical completeness.
Override them and you take control. Here’s the same user as before, now built from toString and valueOf instead of the symbol:
let user = {
name: "Maya",
money: 1000,
// for hint "string"
toString() {
return `{name: "${this.name}"}`;
},
// for hint "number" and "default"
valueOf() {
return this.money;
}
};
alert(user); // toString -> {name: "Maya"}
alert(+user); // valueOf -> 1000
alert(user + 500); // valueOf -> 1500
Same results as the Symbol.toPrimitive version. The difference is bookkeeping: two methods split by hint instead of one method branching internally.
Often you don’t need that split. If you just want a readable representation everywhere, implement toString alone and let it cover every case:
let user = {
name: "Maya",
toString() {
return this.name;
}
};
alert(user); // toString -> Maya
alert(user + 500); // toString -> Maya500
With no Symbol.toPrimitive and no valueOf, the fallback chain reaches toString for every hint. That single method becomes your catch-all.
The method decides the type, not the hint
A hint is a request, not a guarantee. Nothing forces toString to return a string, and nothing forces Symbol.toPrimitive under the "number" hint to return a number. The hint tells your method what the operation hopes for; your method returns whatever primitive it likes.
The one hard rule: the return value must be a primitive, not an object.
What happens after conversion
Object-to-primitive is only the first move. Plenty of operators then convert that primitive again to finish the job.
Take multiplication, which always wants numbers. Feed it an object and two stages run:
- The object becomes a primitive (via the rules above).
- If that primitive isn’t already the right type, it gets converted too.
let obj = {
// toString handles all conversions when nothing else is defined
toString() {
return "2";
}
};
alert(obj * 2); // 4
Walk it through: obj * 2 first turns obj into the primitive string "2". Then "2" * 2 — since * needs numbers — coerces "2" to the number 2, giving 4.
Binary + behaves differently, because a string operand is perfectly acceptable to it. Same object, and the result flips:
let obj = {
toString() {
return "2";
}
};
alert(obj + 2); // "22"
Here obj converts to "2", and + sees a string, so it concatenates: "2" + 2 becomes "22".
Edit what toString returns and watch both operators react. * always forces a second conversion to number; + keeps a string as-is and concatenates. Try "2", then "5", then something non-numeric like "hi":
Summary
Object-to-primitive conversion runs automatically whenever a built-in operator or function needs a primitive but gets an object.
It comes in three hints:
"string"— foralertand other text-hungry operations."number"— for maths."default"— for the few operators that can’t decide; nearly always implemented like"number".
The spec spells out which operator sends which hint.
The engine resolves a conversion like this:
- Call
obj[Symbol.toPrimitive](hint)if that method exists — and stop. - Otherwise, for hint
"string": tryobj.toString(), thenobj.valueOf(). - Otherwise (
"number"or"default"): tryobj.valueOf(), thenobj.toString().
Every one of these methods must return a primitive to count.
In practice you’ll most often define just obj.toString() as a human-readable catch-all — a clean representation for logging and debugging. That single method covers every hint, and it’s enough for the vast majority of objects you’ll write.