How it works
The spec algorithm, simplified from ECMA-262 ToPrimitive:
ToPrimitive(input, preferredType?)
if input is not an Object: return input
method = GetMethod(input, @@toPrimitive)
if method is not undefined:
hint = "default" | "string" | "number" // from preferredType
result = Call(method, input, hint)
if result is not an Object: return result
throw TypeError
OrdinaryToPrimitive(input, preferredType ?? "number")
OrdinaryToPrimitive(obj, hint) builds the list [toString, valueOf] for "string", else [valueOf, toString]. For each name it does Get(obj, name); if the value is callable, calls it with obj as this, and returns the result when it is not an Object. Non-callable properties are skipped silently. After the list, it throws a TypeError.
The spec note says ToPrimitive without a hint "generally behaves as if the hint were number", and names Date and Symbol objects as the only built-ins that override this.
Edge cases
console.log(String({}), String([1, 2]), String(() => 1)); // → [object Object] 1,2 () => 1
console.log([] + {}, {} + []); // → [object Object] [object Object]
const d = new Date(0);
console.log(typeof (d + 1), typeof (d - 1)); // → string number
console.log(d[Symbol.toPrimitive]("number")); // → 0
const bad = { [Symbol.toPrimitive]: () => ({}) };
try {
`${bad}`;
} catch (error) {
console.log(error.constructor.name); // → TypeError
}
const skip = { valueOf: () => ({}), toString: () => "ok" };
console.log(`${skip}`, skip + ""); // → ok ok
console.log(Object(Symbol.iterator) == Symbol.iterator); // → true
d[Symbol.toPrimitive] accepts only the three hint strings; anything else throws a TypeError. A null-prototype object (Object.create(null)) has neither method, so any primitive conversion throws.
Real-world scenario
A money or duration value object: comparisons and arithmetic should use the number, while logging should format it. A single Symbol.toPrimitive serves both. Note the default hint: money + 5 and money == 5 both receive "default", so treat it the same as "number" unless your type has a good reason not to.
class Duration {
constructor(ms) {
this.ms = ms;
}
[Symbol.toPrimitive](hint) {
return hint === "string" ? `${this.ms / 1000}s` : this.ms;
}
}
const timeout = new Duration(1500);
console.log(`${timeout}`, timeout > 1000, timeout + 500); // → 1.5s true 2000
Trade-offs
Overriding valueOf/toString works everywhere and is how older code did it, but you cannot tell the hints apart and the two methods double as the public string and debugging representation. Symbol.toPrimitive is explicit but invisible to readers who do not know it, and implicit numeric coercion of objects makes call sites harder to read. Prefer explicit methods (.toNumber(), .format()) for domain types; reserve Symbol.toPrimitive for value-like objects where operators are natural.