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:

  1. Boolean conversion never runs for objects. Every object is truthy. An empty object, an empty array, even new Boolean(false) — all true in a boolean context. There’s nothing to customize, so only string and number conversions remain.
  2. Numeric conversion fires when you do maths on an object — subtraction, comparison with </>, Number(obj), most math functions.
  3. String conversion fires when the context wants text, most visibly alert(obj), or using an object as another object’s property key.
string
alert(obj)
anotherObj[obj] = 1
number
+obj, obj1 - obj2
obj1 > obj2
boolean
always true
(no conversion)
Objects convert on demand — never to boolean, only to string or number.

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.

operationhint
alert(obj), String(obj)“string”
+obj, obj - obj, Number(obj)“number”
obj < obj, obj > obj“number”
obj + obj, obj == primitive“default”
Which operation triggers which hint.

The algorithm

Whatever the hint, the engine hunts for up to three methods, in this order:

  1. If obj[Symbol.toPrimitive](hint) exists, call it. Done — its return value is the result, and no other method is consulted.
  2. Otherwise, if the hint is "string": try obj.toString(), then fall back to obj.valueOf().
  3. Otherwise (hint is "number" or "default"): try obj.valueOf(), then fall back to obj.toString().
1
objSymbol.toPrimitive exists? → use it, stop
2
hint “string” → toString() then valueOf()
3
hint “number”/“default” → valueOf() then toString()
The conversion algorithm: one modern method, or a pair of old ones whose order depends on the hint.

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:

interactiveOne method, three hints

toString / valueOf

When Symbol.toPrimitive is absent, the engine falls back to the older pair, toString and valueOf:

  • Hint "string": call toString first. If it’s missing or returns an object, try valueOf. So toString has priority for text.
  • Hints "number" and "default": call valueOf first. If it’s missing or returns an object, try toString. So valueOf has 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.

toString()
returns “[object Object]”
→ a primitive, used
valueOf()
returns the object itself
→ not a primitive, ignored
Default conversion methods on a plain object, and what the engine does with each result.

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:

  1. The object becomes a primitive (via the rules above).
  2. 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".

obj * 2
obj → “2”
“2”2 (* needs number)
result 4
obj + 2
obj → “2”
“2” stays string (+ accepts it)
result “22”
Same object, different operator: the second conversion is what diverges.

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":

interactiveThe second conversion is what diverges

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" — for alert and 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:

  1. Call obj[Symbol.toPrimitive](hint) if that method exists — and stop.
  2. Otherwise, for hint "string": try obj.toString(), then obj.valueOf().
  3. Otherwise ("number" or "default"): try obj.valueOf(), then obj.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.