skip to content

Why does `const s = Symbol('id'); console.log('id: ' + s);` throw in JavaScript, while `String(s)` returns a string?

level: middleimportance: should knowfreq 38%

answer

  1. implicit throws, explicit is allowed
  2. protects the key from becoming a string
  3. template literals coerce too
  4. number always throws, boolean never
  5. description gives the bare label

basics

~20 s

Implicit coercion of a symbol to a string throws a TypeError by design, so a symbol can never silently become a string property key. String(s) and s.toString() are explicit requests, and both return the string 'Symbol(id)'.

solid answer

~40 s

`Symbol.prototype` has no implicit string conversion: whenever the language would coerce a symbol to a string automatically — the `+` operator, a template literal `${s}`, using it where a string is required — it throws `TypeError: Cannot convert a Symbol value to a string`. That is deliberate. Symbols exist to be property keys that never collide with strings, so silently turning one into `'Symbol(id)'` would create exactly the collision the type is meant to prevent, and the bug would be invisible. Explicit conversions are allowed: `String(s)` special-cases symbols and returns `'Symbol(id)'`, and `s.toString()` returns the same. Numeric coercion always throws — there is no explicit escape hatch — while boolean coercion never does, so every symbol is truthy and `if (s)` is safe. To log one, use `String(s)` or `s.description`.

go deeper

for a junior

Know that putting a symbol into a string — with + or inside a template literal — throws, and that String(sym) or sym.description is the safe way to log one.

for a middle

Explain the design reason: implicit conversion is blocked so a symbol can never silently become a string property key, while String() and toString() are explicit carve-outs that return 'Symbol(desc)'.

for a senior

Show the diagnosis path — a Cannot convert a Symbol value to a string in production almost always means a key from Reflect.ownKeys reached a template literal — and note that number coercion has no escape hatch while boolean coercion never throws.

for a principal

Use this as the worked example of when a language should refuse to coerce: the silent conversion would defeat the very guarantee the type exists to provide, so a loud failure at the site of the mistake is cheaper than corrupted data downstream.

## The behaviour ```js const s = Symbol('id'); 'id: ' + s; // TypeError: Cannot convert a Symbol value to a string `${s}`; // TypeError — same reason 'x'.concat(s); // TypeError String(s); // 'Symbol(id)' ✅ s.toString(); // 'Symbol(id)' ✅ s.description; // 'id' ✅ +s; // TypeError: Cannot convert a Symbol value to a number s > 1; // TypeError Boolean(s); // true — never throws !s; // false ``` So of the three coercion targets, symbols behave differently in each: string conversion throws implicitly but has explicit escape hatches, number conversion throws with no escape hatch at all, and boolean conversion always succeeds with `true`. ## Why the spec throws The reasoning is the whole purpose of the type. A symbol is meant to be a property key that cannot collide with a string key. Suppose implicit coercion produced `'Symbol(id)'` instead of throwing: ```js const ID = Symbol('id'); const obj = {}; obj[ID] = 1; // symbol key obj['Symbol(id)'] = 2; // an ordinary string key — and now, a collision ``` Worse, the collision would be silent and would depend on a *description* that was supposed to be debug-only. Two libraries whose symbols both describe themselves as `'id'` would suddenly share a slot. Making the conversion throw forces the mistake to surface at the point where it happens, loudly, rather than as corrupted data three layers away. This is one of the rare places where JavaScript chose a hard error over a coercion, and the reason is that the alternative would silently defeat a language feature. ## Why `String()` is allowed anyway `String(value)` contains an explicit carve-out: if the argument is a symbol, it returns `SymbolDescriptiveString(value)` — `'Symbol('` + description + `')'` — instead of running the ordinary ToString algorithm. `Symbol.prototype.toString()` does the same. The distinction the spec is drawing is **intent**: `String(s)` is unambiguous — you asked for the debug text and you will get it. `'x' + s` is ambiguous — the programmer very likely does not realise a symbol is in the expression at all. Note that `String(s)` and `new String(s)` differ: the latter throws, because construction runs the ordinary ToString path. ## The template-literal trap The most common way to hit this in real code is logging: ```js console.log(`key = ${s}`); // 💥 TypeError at runtime console.log('key =', s); // fine — console does not coerce console.log(`key = ${String(s)}`); // fine console.log(`key = ${s.description}`); // fine, prints 'id' ``` A template literal applies ToString to every substitution, so it throws exactly like `+`. Passing the symbol as a separate `console.log` argument does not coerce it at all, which is why the bug often survives development and only appears once someone rewrites a log line into a template. ## What does *not* throw - **Boolean coercion.** Symbols are objects-of-interest to nobody's falsiness table: `Boolean(sym)` is `true`, so guards like `if (key)` work. - **Equality.** `s === s` and `s == s` are fine; comparison never coerces a symbol. But `s == 'Symbol(id)'` is simply `false`, not an error — loose equality with a string does not attempt symbol-to-string conversion. - **Use as a property key.** `obj[s]` uses the symbol as a key directly; no conversion happens, which is precisely the case the whole rule protects. - **`JSON.stringify`.** It does not throw on symbols; it silently omits them (or emits `null` for an array element). ## Diagnosing it The message — `Cannot convert a Symbol value to a string` in V8, wording varies by engine — is specific enough that it names the cause outright. When you see it, look for a template literal, a `+`, or a string method applied to something that turned out to be a symbol; the usual culprit is a value that came from `Reflect.ownKeys` or `Object.getOwnPropertySymbols` and was then interpolated into a message. The fix is always the same: convert explicitly with `String(key)`, or use `key.description` when you want just the label without the `Symbol(...)` wrapper. ## Version note This behaviour has been in the language since symbols were introduced in ES2015. The `description` accessor is the newer part, added in ES2019; before it, `sym.toString().slice(7, -1)` was the idiom.

  • Is there any way to coerce a symbol to a number?
    No. Numeric coercion of a symbol always throws a `TypeError`, and unlike string conversion there is no explicit escape hatch — `Number(sym)` throws just as `+sym` does. Symbols are not ordered either, so relational comparisons like `sym > 1` throw for the same reason. Only boolean conversion is total: every symbol is truthy.
  • Does `sym == 'Symbol(id)'` throw, given that loose equality is famous for coercing?
    No, it evaluates to `false`. The loose-equality algorithm has no symbol-to-string case, so it never attempts the conversion and simply reports that the two values are not equal. A symbol is only ever loosely or strictly equal to itself, or to its own wrapper object under `==`.
  • What is the difference between `String(sym)` and `sym.description` for logging?
    `String(sym)` returns the full debug form `'Symbol(id)'`, and works even when the symbol has no description — you get `'Symbol()'`. `sym.description` returns just the raw label `'id'`, or `undefined` for `Symbol()` with no argument. Use `String()` in error messages where you want the value's identity visible, and `description` when you want the bare name.

saying these in an interview costs you the question

  • Expects `'x' + sym` to produce 'Symbol(x)'
  • Thinks template literals are safe because they are not the + operator
  • Says String(sym) throws just like implicit coercion
  • Claims symbols are falsy or that if (sym) throws
  • Believes Number(sym) works because String(sym) does

context