Skip to content

How coercion works in JavaScript

JavaScript silently converts one type into another all the time. A note on why 1 + "2" is "12" and 1 - "2" is -1, and what the engine actually does between the lines.

You’ve probably seen something like this:

1 + "2"; // "12"
1 - "2"; // -1
"5" * "3"; // 15
[] + {}; // "[object Object]"
if ("0") {
} // enters - "0" is truthy

It looks chaotic, but underneath, the same thing happens every time: the engine got a value that wasn’t the type it needed, and silently converted it. This process is called coercion. It happens constantly: in expressions with + or ==, in if conditions, in template literals `${...}`, in arguments to Number(...). Four internal engine operations sit underneath it - and we’re going to walk through them here.

#When the engine gets an object instead of a value

The most interesting case is the one where one side of an operation is an object - an array, {}, Date, anything. The engine can’t add an object to a number or compare one to a string, so it has to reduce it to something simpler first. It does this through an internal operation called ToPrimitive.

ToPrimitive works like a conversation with the object: it asks whether it has a primitive form. The object can answer through two methods:

  • valueOf() - “give me your numeric value”.
  • toString() - “give me your textual form”.

The engine calls them in order and takes the first result that is a primitive (a number, string, or boolean). If a method returns another object, that result is skipped. If neither returns a primitive - you get a TypeError.

The question is: which method goes first? It depends on what the engine needs. The spec calls this signal a hint:

  • hint = "number" - when the engine needs a number (-, *, /, <, >, Number(x)). valueOf first, then toString.
  • hint = "string" - when the engine needs a string (template literal, String(x)). The other way around: toString first, then valueOf.
  • hint = "default" - for == and for + with two operands (e.g. obj + 1, a + b). Most objects treat this as "number". The exception is Date, which maps "default" to "string" - because for a date, getting text out is more useful than a timestamp.

The easiest way to see this is on an object where you define both methods yourself. Have them return clearly different values, so you can tell from the result which one the engine called first:

const obj = {
  valueOf() {
    return 42;
  },
  toString() {
    return "ala";
  },
};

Number(obj); // 42    -> Number() asks for hint "number", valueOf goes first
`${obj}`; // "ala" -> template literal asks for hint "string", toString goes first

Same object, two different hints, two different results. If one of the methods returned an object instead of a primitive (so anything other than a number, string, or boolean), the engine would skip it and reach for the other one. If both returned objects - TypeError.

The whole “magic” of [] + {} lives right here: + with two operands is hint "default", which means "number". The engine asks [] for a numeric value, gets "". Asks {} for the same, gets "[object Object]". Neither is a number, but both are primitives - so the engine glues them together as strings. Strange result, deterministic steps.

#Three conversions you use every day

Alongside ToPrimitive, the engine defines three operations that handle conversion to a specific type. They run explicitly when you write Number(x), String(x), or Boolean(x) - and implicitly anywhere a type needs to be reconciled on the fly (+, ==, if, template literals):

  • ToString(x) - returns a string. If x is an object, it first runs ToPrimitive with hint "string", then converts the result to a string.
  • ToNumber(x) - returns a number. Same idea, but with hint "number".
  • ToBoolean(x) - returns a boolean. The only one of the three that doesn’t use ToPrimitive.

That last point is a quiet gotcha. ToBoolean doesn’t ask the object anything - it just checks whether the value is on a short list of falsy values written directly into the spec: 0, "", null, undefined, NaN, 0n. Everything outside that list is true. Which is why:

Boolean({}); // true - {} isn't on the falsy list
Boolean([]); // true - [] isn't either, even though it's empty
Boolean("0"); // true - "0" is a non-empty string

Even when empty, an array and an object are regular reference values to ToBoolean. For ToString they’d be "" and "[object Object]", for ToNumber they’d be 0 and NaN - but ToBoolean doesn’t even look inside them.

#Explicit and implicit - two styles of coercion

In practice, you do coercion in code in two ways:

  • Explicit - you say outright what you want: Number("42"), String(true), Boolean(0). Intent visible at a glance.
  • Implicit - it happens by itself, in the background: "5" - 2, if (obj), `date: ${date}`. Shorter, cleaner, but it requires you to know what types you expect on both sides.

Implicit coercion has a bad reputation because it’s easy to talk about it as “magic” that surprises. But there’s no magic underneath - it’s the same four operations we just covered. The only difference is whether the engine calls ToNumber, or you do.

The rule isn’t “don’t use implicit coercion”, it’s “know your types”. When you know what’s on both sides of == or +, the engine does the sensible thing, and implicit coercion becomes a shortcut, not a trap. When you don’t, reach for explicit, so the code itself spells out the intent.

← Back to notes