You are writing a Money class in JavaScript whose instances get logged, concatenated and compared. How do you take control of what those instances convert to, and what should the object do when it receives the "default" hint?
answer
- one hook beats two legacy methods
- the method receives the hint
- must return a primitive or throw
- default is where + and == land
- loud failure over a silent wrong number
basics
~20 sImplement a Symbol.toPrimitive method taking the hint: return formatted text for "string", a number for "number", and for "default" either pick one deliberately or throw, so ambiguous uses like money1 + money2 fail loudly instead of silently concatenating.
solid answer
~40 sDefine `[Symbol.toPrimitive](hint)` on the class. It is consulted before `valueOf` and `toString`, it receives `'string'`, `'number'` or `'default'`, and it must return a primitive or the engine throws. For a money type I return the formatted amount for `'string'`, the numeric amount for `'number'`, and for `'default'` — the hint that binary `+` and `==` pass — I usually **throw**, because `a + b` on two money values is either an addition that must respect currency or an accidental string concatenation, and neither should happen implicitly. Then expose named operations: an `add()` that checks currency, a `toJSON()` for serialisation, a `format()` for display. Keep a plain `toString()` too, since `console.log` and debuggers use their own inspection paths rather than ToPrimitive.
code
javascript · 13 linesclass Temperature {
constructor(celsius) { this.celsius = celsius; }
[Symbol.toPrimitive](hint) {
if (hint === 'string') return `${this.celsius}°C`;
return this.celsius; // number and default
}
}
const t = new Temperature(21);
console.log(`${t}`); // '21°C'
console.log(t * 2); // 42
console.log(t + 1); // 22 — default mapped to number
console.log(t > new Temperature(18)); // truego deeper
Know that a class can control its string form by defining toString, and that without one an instance prints as '[object Object]' in string contexts.
Explain the Symbol.toPrimitive contract: it receives the hint, takes precedence over valueOf and toString, and must return a primitive. Map each operator to the hint it sends.
Argue the design call for the default hint — number, string, or throw — in terms of the bug each choice permits, and name the surfaces the hook does not cover: JSON.stringify, console inspection, sort without a comparator, object keys.
Set the house rule: domain value types that discard information when flattened should not convert implicitly at all. Weigh developer convenience against silent unit and currency errors, and require tests that pin coercion behaviour.
## The hook and its contract `Symbol.toPrimitive` is a well-known symbol whose method, if present on an object, replaces the whole ordinary conversion path. The contract is small and strict: - it is called with exactly one argument, the hint string: `'string'`, `'number'` or `'default'`; - its return value **must** be a primitive — returning an object throws `TypeError: Cannot convert object to primitive value`; - when it exists, `valueOf` and `toString` are never consulted for coercion. ```js class Money { #cents; #currency; constructor(cents, currency) { this.#cents = cents; this.#currency = currency; } [Symbol.toPrimitive](hint) { if (hint === 'number') return this.#cents; if (hint === 'string') return this.format(); throw new TypeError('Money does not support implicit conversion; use add() or format()'); } format() { return new Intl.NumberFormat('en-US', { style: 'currency', currency: this.#currency }) .format(this.#cents / 100); } add(other) { if (other.#currency !== this.#currency) throw new TypeError('currency mismatch'); return new Money(this.#cents + other.#cents, this.#currency); } toJSON() { return { cents: this.#cents, currency: this.#currency }; } toString() { return this.format(); } } ``` ## Which hint each operator sends Designing the method means knowing who calls it. `String(m)`, `` `${m}` `` and string-position uses send `'string'`. `Number(m)`, unary `+m`, `-`, `*`, `/` and the relational operators `<` `>` `<=` `>=` send `'number'`. Binary `+` and `==` against a primitive send `'default'`. That mapping is what makes `'default'` the interesting decision. Two sane designs exist. **Numeric default.** Return the number for both `'number'` and `'default'`. `m + 0` and `m1 + m2` then "work" arithmetically — but they work *wrongly* for money, because the sum loses the currency and produces a bare number that no longer type-checks as money. Convenient, and quietly dangerous. **Throwing default.** Refuse the ambiguous case. `m1 + m2` throws with a message pointing at `add()`, and `'Total: ' + m` throws too — which is a fair trade, because the caller should write `` `Total: ${m}` `` (string hint, fine) or `m.format()`. This is the choice I would defend for a domain value type: implicit conversion is exactly where currency, precision and unit bugs hide, and a loud `TypeError` in development beats a wrong number in a ledger. A third option, string default, is what `Date` does — sensible when the type's readable form is what people concatenate and its numeric form is a technical detail. ## What Symbol.toPrimitive does *not* cover Several surfaces bypass ToPrimitive entirely, and expecting it to cover them is a common design error. - **`JSON.stringify`** uses `toJSON()` if present and otherwise serialises own enumerable properties. Private fields are invisible to it, so a class with `#`-fields and no `toJSON` serialises as `{}`. - **`console.log`** in browsers and Node uses host inspection (Node's `util.inspect`), not string conversion, so your formatting will not appear unless you also account for it; a plain `toString()` at least helps in string contexts and some tooling. - **Property keys**: `obj[money]` runs `ToPropertyKey`, which calls ToPrimitive with the string hint, then `ToString`. A `Map` key, by contrast, is the object itself with no conversion. - **`===` and `==` between two objects** never convert at all — they compare references. Only `==` with a primitive on the other side triggers the default hint. - **Sorting**: `Array.prototype.sort` without a comparator converts elements to strings, which for a money type means lexicographic order. Always pass a comparator that uses the numeric accessor. ## Reviewing it as a design decision The judgment an interviewer is listening for is not "I know the symbol exists" but *when to use it at all*. Implicit conversion is a feature for types with one obvious primitive reading — a temperature, a duration, a version-less identifier. For types where the primitive reading discards information (currency, unit, time zone, precision), the better API is named methods plus a throwing or string-only conversion, so that misuse surfaces at the call site. Whichever way you go, write the tests that pin the behaviour down: assert `` `${m}` ``, `Number(m)`, and that `m1 + m2` does the thing you chose — sums correctly or throws. Coercion behaviour is invisible in the type's method list, so it needs an explicit test to stay stable as the class evolves.
- If a class defines both Symbol.toPrimitive and valueOf, which one runs?`Symbol.toPrimitive` wins for every coercion — the specification checks for it first and, when it exists, never falls back to `valueOf` or `toString`. Those two remain callable explicitly and are still used by code paths outside ToPrimitive, but they no longer affect `+`, `==`, `String()` or `Number()`. Returning a non-primitive from the symbol method throws rather than falling back.
- Will your Symbol.toPrimitive control how the instance appears in JSON.stringify?No. `JSON.stringify` never calls ToPrimitive; it calls `toJSON()` if present, otherwise serialises own enumerable properties. So a class with private `#` fields and no `toJSON` produces `{}` regardless of its conversion hook. Serialisation and coercion are separate concerns and each needs its own method.
- What breaks if such instances are sorted or used as plain-object keys?`sort()` without a comparator converts elements to strings and orders lexicographically, so numeric-looking values sort wrongly — always pass an explicit comparator. Used as a plain-object key, the instance runs through `ToPropertyKey`, which takes the string hint and then `ToString`, so distinct instances with the same text collapse into one key. Keep the object identity by using a `Map` instead.
saying these in an interview costs you the question
- Thinks valueOf still runs when Symbol.toPrimitive exists
- Returns an object from Symbol.toPrimitive
- Expects the hook to change JSON.stringify output
- Treats the hint argument as optional or ignores it
- Assumes a numeric default is always the safe choice