skip to content

When JavaScript needs a primitive out of an object, it runs the ToPrimitive operation with a hint. What are the possible hints, and in what order does the engine try valueOf and toString for each?

level: middleimportance: must knowfreq 55%

answer

  1. objects are converted before operators run
  2. three hints, one preference each
  3. hint decides which method is tried first
  4. string hint flips the usual order
  5. valueOf returns this for plain objects

basics

~20 s

ToPrimitive takes a hint of "number", "string", or "default". For number and default it calls valueOf first, then toString; for string it calls toString first, then valueOf. The first call returning a primitive wins; otherwise a TypeError is thrown.

solid answer

~50 s

Any operator that needs a primitive but gets an object runs ToPrimitive with one of three hints. `String(obj)` and template interpolation pass **string**; unary `+`, `-`, `*`, `<` and `Number(obj)` pass **number**; binary `+` and `==` pass **default**, which every built-in except `Date` treats exactly like number. If the object has a `Symbol.toPrimitive` method it is called with the hint and its result must be a primitive. Otherwise the engine runs the ordinary path: for number and default it tries `valueOf()` then `toString()`, for string it tries `toString()` then `valueOf()`, taking the first result that is a primitive. Plain objects inherit a `valueOf` that returns `this` — not a primitive — so they fall through to `toString()` and become `"[object Object]"`. If both steps return objects, you get `TypeError: Cannot convert object to primitive value`.

code

javascript · 11 lines
javascript
const box = {
  value: 42,
  valueOf() { return this.value; },
  toString() { return `Box(${this.value})`; }
};

console.log(box * 2);        // 84  — number hint: valueOf first
console.log(`${box}`);       // 'Box(42)' — string hint: toString first
console.log(box + 1);        // 43  — default hint behaves like number
console.log(String(box));    // 'Box(42)'
console.log(Number(box));    // 42

go deeper

for a junior

Recall the three hints and that String() prefers toString while arithmetic prefers valueOf. Be able to say why a plain object printed inside a string becomes "[object Object]".

for a middle

Explain the full algorithm out loud: Symbol.toPrimitive first, otherwise valueOf/toString ordered by hint, first primitive wins, TypeError if neither yields one. Map concrete operators to the hint they pass.

for a senior

Show you use this to diagnose real bugs — an id object landing in a URL as "[object Object]", a null-prototype config object throwing on string interpolation, a wrapper type comparing wrongly in a sort. Say how you make conversion explicit at the type's boundary.

for a principal

Own the API-design angle: decide whether a domain type should be implicitly convertible at all, and argue for throwing on ambiguous conversion instead of silently producing a plausible-looking string that leaks into logs, cache keys and query parameters.

## The problem ToPrimitive solves JavaScript operators such as `+`, `-`, `<` and `==` are defined over primitive values — string, number, bigint, boolean, symbol, null, undefined. When you hand one of them an object, the specification does not fail; it first converts the object to a primitive through an internal operation called **ToPrimitive**. Almost every surprising coercion result comes from being able to predict what ToPrimitive returned. ToPrimitive takes two arguments: the value, and a *hint* — a string telling the object what kind of primitive the caller would prefer. There are exactly three hints. | Hint | Who passes it | |---|---| | `"string"` | `String(obj)`, template interpolation `` `${obj}` ``, property keys, `alert` | | `"number"` | `Number(obj)`, unary `+obj`, `-`, `*`, `/`, `%`, and relational `<` `>` `<=` `>=` | | `"default"` | binary `+`, and `==` when one side is an object | The hint is a *preference*, not a guarantee. `String({})` asks for a string and gets one, but `Number({})` asks for a number and gets `NaN` — because the object produced the string `"[object Object]"`, which `ToNumber` then failed to parse. ## The two-branch algorithm Step one: does the object have a `Symbol.toPrimitive` method (its own or inherited)? If so, the engine calls it with the hint as the single argument and uses the result. That result **must** be a primitive; returning an object from it throws a `TypeError`. ```js const temp = { celsius: 21, [Symbol.toPrimitive](hint) { return hint === 'string' ? `${this.celsius}°C` : this.celsius; } }; `${temp}` // '21°C' (hint 'string') temp * 2 // 42 (hint 'number') ``` Step two, if there is no such method: the engine runs **OrdinaryToPrimitive**, which tries two ordinary methods in an order chosen by the hint. - hint `"string"` → try `toString()`, then `valueOf()` - hint `"number"` or `"default"` → try `valueOf()`, then `toString()` Each candidate is called only if it is callable, and its result is accepted only if it is a primitive. Note the important detail: `"default"` behaves identically to `"number"` in this path. The distinction between them exists so that a type can *choose* to treat them differently — and in the standard library exactly one type does, `Date`. ## Why plain objects become "[object Object]" `Object.prototype.valueOf` is defined to return `this`. For a plain object that is the object itself — not a primitive — so the number/default path rejects it and falls through to `Object.prototype.toString`, which returns `"[object Object]"`. That is why `{} + ''`, `` `${{}}` `` and `String({})` all produce the same string. Arrays are the same story with a different `toString`: `Array.prototype.toString` calls `join(',')`, so `[1, 2].toString()` is `'1,2'` and `[].toString()` is `''`. That empty string is why `Number([])` is `0` and `[] + 1` is `'1'`. ```js String([1, 2]) // '1,2' Number([]) // 0 ('' → 0) Number([7]) // 7 ('7' → 7) Number([1, 2]) // NaN ('1,2' does not parse) ``` ## When conversion throws If both candidate methods are missing or both return objects, ToPrimitive throws `TypeError: Cannot convert object to primitive value`. The everyday way to hit this is an object with a null prototype, which inherits neither method: ```js const bare = Object.create(null); bare + ''; // TypeError: Cannot convert object to primitive value String(bare); // also throws JSON.stringify(bare); // '{}' — no conversion needed, works fine ``` The same happens if you deliberately write `{ valueOf: () => ({}), toString: () => ({}) }`. ## Reading real code with this The practical payoff is that you can evaluate coercions instead of memorising outcomes. `[] + {}` → both operands take the default hint → `''` and `'[object Object]'` → since one is a string, `+` concatenates → `'[object Object]'`. `[10] * 2` → number hint → `'10'` → `20`. `` `${[1,[2,3]]}` `` → string hint → nested `join` → `'1,2,3'`. And the design lesson: if a class of yours is ever concatenated, compared or logged, decide its conversion explicitly with `Symbol.toPrimitive` or a real `toString`, rather than letting it degrade into `"[object Object]"` inside a log line or a query string.

  • If the hint is "number" but the object only has a toString, what do you get?
    The engine tries `valueOf` first, finds the inherited `Object.prototype.valueOf` which returns the object itself, rejects it as non-primitive, and falls through to your `toString`. The resulting string is then passed to `ToNumber`, so `{ toString: () => '7' } * 2` is `14`, while `{ toString: () => 'x' } * 2` is `NaN`. The hint never forces the method to return a number.
  • Where does the "default" hint differ from "number" in practice?
    Only where a type distinguishes them, and in the standard library that means `Date`. `Date.prototype[Symbol.toPrimitive]` treats `"default"` like `"string"`, so `date + 1` concatenates while `date - 1` and `+date` use the timestamp. For every other built-in and for objects using the ordinary path, default and number produce identical results.
  • Does JSON.stringify go through ToPrimitive?
    No. `JSON.stringify` has its own algorithm: it calls a `toJSON()` method if one exists, then serialises by type. `valueOf` is ignored, and an object with a null prototype serialises fine as `{}` even though `String()` on it throws. That is why an object can log as `"[object Object]"` yet stringify to useful JSON.

saying these in an interview costs you the question

  • Says toString is always tried before valueOf
  • Thinks the number hint forces the result to be a number
  • Believes the hint is chosen by the object, not the operator
  • Claims plain objects have no valueOf at all
  • Assumes conversion can never throw

context