bastianplsfix

Conversion and coercion

A value changes type in one of two ways. Either you ask, with String, Number, Boolean, or Object, or an operation asks on your behalf when it is handed something it cannot use. The second kind is called coercion, and it is the source of JavaScript's worst reputation.

The useful thing to know first: TypeScript refuses most of it. "7" * "3" does not compile, and this page proves that with a diagnostic. In typed code, coercion is a story about the boundaries of your program, where data arrives untyped, rather than about the arithmetic in the middle of it.

Create programs/conversion-and-coercion.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

each conversion is a function named after its type

Start with the asking kind. Each conversion is a function named after the type it produces, called without new:

Deno.test("each conversion is a function named after its type", () => {
assertEquals(String(123), "123");
assertEquals(String(null), "null");
assertEquals(Number("123"), 123);
assertEquals(Boolean(0), false);
assertEquals(typeof Object(123), "object");
});
Check programs/conversion-and-coercion.test.ts
running 1 test from ./programs/conversion-and-coercion.test.ts
each conversion is a function named after its type ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

Walk the five conversions.

  1. String(123) produces the text "123", and String(null) produces the text "null": every value has a string form, even the absences.
  2. Number("123") reads the text back into a number. Its full set of rules, including the surprises, lives in the numbers page's three parsers, three sets of rules.
  3. Boolean(0) follows the eight-value list from the truthiness page's exactly eight values are falsy, and nothing else.
  4. Object(123) produces an object, and it is the odd one out. What kind of object comes back is a step of its own, further down.

a template literal converts what you interpolate

Coercion still happens in checked code, in three legitimate places, and this is the first. A template literal converts whatever sits inside ${}. Predict the last line, then save:

Deno.test("a template literal converts what you interpolate", () => {
const total = 10;
assertEquals(`${total}`, "10");
assertEquals(`${null}`, "null");
assertEquals(`${[1, 2]}`, "1,2");
assertEquals(`${{}}`, "{}");
});
Check programs/conversion-and-coercion.test.ts
running 2 tests from ./programs/conversion-and-coercion.test.ts
each conversion is a function named after its type ... ok (0ms)
a template literal converts what you interpolate ... FAILED (10ms)

ERRORS

a template literal converts what you interpolate => ./programs/conversion-and-coercion.test.ts:12:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

- [object Object]
+ {}

FAILURES

a template literal converts what you interpolate => ./programs/conversion-and-coercion.test.ts:12:11

FAILED | 1 passed | 1 failed (12ms)

error: Test failed

An empty object does not interpolate as {}. It interpolates as [object Object], and that string is how most objects appear in production logs. Take the four lines in order.

  1. `${total}` converts the number 10 to "10", the ordinary case and the reason the feature exists.
  2. `${null}` converts to the text "null", same as String(null) in the previous step.
  3. `${[1, 2]}` converts an array by converting each element and joining with commas: "1,2".
  4. `${{}}` converts a plain object, and a plain object's string form is [object Object]: the word "object" twice, and none of the contents.

Correct the expected text to "[object Object]":

a template literal converts what you interpolate ... ok (0ms)

The lesson from the failure: when you want to see inside an object, do not interpolate it into a message. Print the value itself and let the console render its contents, or serialize it deliberately with JSON.stringify.

a property key is always text

The second place coercion survives in checked code: an object's property keys. Every key is a string, including the ones that look like numbers.

Deno.test("a property key is always text", () => {
const keyed: Record<string, string> = {};
keyed[1] = "one";
assertEquals(Object.keys(keyed).join(), "1");

const byNumber = new Map<number, string>([[1, "one"]]);
assertEquals(byNumber.get(1), "one");
assertEquals(byNumber.get("1" as unknown as number), undefined);
});
a property key is always text ... ok (0ms)

Follow the number.

  1. keyed[1] = "one" writes through the number 1, and the object converts it to the string "1" on the way in, because an object key can only be text.
  2. Object.keys(keyed) returns ["1"]: the string, not the number. An object cannot have a numeric key, only a key that looks numeric.
  3. The Map keeps the real number. byNumber.get(1) finds the entry, and byNumber.get("1"), forced past the checker with a cast, finds nothing, because "1" and 1 are different keys to a Map.

A Map holding honest key types is one more reason to reach for it, alongside Map keys compare by identity from the values and references page. The third legitimate coercion site, a condition converting to boolean, is the entire subject of the truthiness page.

+ joins when either side is text

+ is the only arithmetic-looking operator that survives the checker with mixed operands, because joining text is a real thing to want. Predict 1 + "2". If your prediction is the number 3, write exactly that and save:

Deno.test("+ joins when either side is text", () => {
assertEquals(1 + 2, 3);
assertEquals(1 + "2", 3);
});
Check programs/conversion-and-coercion.test.ts
TS2345 [ERROR]: Argument of type 'number' is not assignable to parameter of type 'string'.
assertEquals(1 + "2", 3);
^
at file:///programs/conversion-and-coercion.test.ts:32:27

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

The program never ran, and the diagnostic already contains the answer. Read it as evidence.

  1. assertEquals requires its two arguments to share a type, and the checker inferred the type of 1 + "2" on its own.
  2. The error says the expected value 3, a number, does not fit a parameter of type string. So the checker knows, before anything executes, that 1 + "2" produces a string.
  3. The rule it applied: + converts both operands to primitives, and if either one is then a string, it joins them; otherwise it adds them.

Correct the prediction to the string "12", and add the chains that follow from the same rule read left to right:

Deno.test("+ joins when either side is text", () => {
assertEquals(1 + 2, 3);
assertEquals(1 + "2", "12");
assertEquals("3" + 4, "34");
assertEquals(typeof (1 + "2"), "string");
assertEquals(1 + 2 + "3", "33");
assertEquals("1" + 2 + 3, "123");
});
+ joins when either side is text ... ok (0ms)

The two chains earn a close look, because they run left to right.

  1. 1 + 2 + "3" starts with 1 + 2, two numbers, so it adds to 3. Then 3 + "3" meets a string and joins: "33".
  2. "1" + 2 + 3 starts with "1" + 2, which joins to "12". From there everything is text: "12" + 3 is "123". One string anywhere in a chain turns the rest of it into text.

The practical rule: build text with a template literal, keep + for arithmetic, and this question never comes up in your own code.

the checker refuses the rest of the arithmetic

Every arithmetic operator except + requires numbers, and this is where the famous coercion trivia goes to die. Try the classic:

Deno.test("the checker refuses the rest of the arithmetic", () => {
assertEquals("7" * "3", 21);
});
Check programs/conversion-and-coercion.test.ts
TS2362 [ERROR]: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
assertEquals("7" * "3", 21);
~~~
at file:///programs/conversion-and-coercion.test.ts:40:18

TS2363 [ERROR]: The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
assertEquals("7" * "3", 21);
~~~
at file:///programs/conversion-and-coercion.test.ts:40:24

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

Two diagnostics for one line, one per operand. "7" * "3" is real JavaScript with a real answer, 21, and it cannot be written in a checked file. That is worth sitting with, because it changes what this topic is for. The coercion tables passed around as interview trivia describe a language you are not writing. What you are writing is a program with typed edges, and coercion is what happens when a value crosses one of those edges untyped: a form field, a query parameter, a JSON body, a value hiding behind any.

When you actually hold two numeric strings, ask for the conversion by name and the arithmetic is ordinary:

Deno.test("the checker refuses the rest of the arithmetic", () => {
assertEquals(Number("7") * Number("3"), 21);
});
the checker refuses the rest of the arithmetic ... ok (0ms)

an object decides through valueOf and toString

When an object must become a primitive, the language does not guess. It asks the object, through a protocol of up to three methods, and it passes a hint naming what kind of primitive the context wants. An object can answer the number hint and the string hint with two ordinary methods:

Deno.test("an object decides through valueOf and toString", () => {
const money = {
amount: 5,
valueOf() {
return this.amount;
},
toString() {
return `$${this.amount}`;
},
};
assertEquals(Number(money), 5);
assertEquals(`${money}`, "$5");
assertEquals(String(money), "$5");
assertEquals((money as unknown as number) + 1, 6);
});
an object decides through valueOf and toString ... ok (0ms)

Match each result to the hint that produced it.

  1. Number(money) is a numeric context, so the hint is "number", which reaches valueOf and gets 5.
  2. `${money}` and String(money) are text contexts, so the hint is "string", which reaches toString and gets "$5".
  3. money + 1 is the interesting one. + cannot know in advance whether it will add or join, so it passes the third hint, "default". For an ordinary object, "default" behaves like the number hint, so valueOf answers 5 and the sum is 6 rather than "$51". The cast on that line exists only because the checker, reasonably, wants to know what a money plus a number means before allowing it.

Symbol.toPrimitive sees the hint

The three-method protocol can be replaced by one. A method keyed by Symbol.toPrimitive, written with computed-method syntax, receives the hint as its argument, and answering with the hint's own name makes the machinery visible:

Deno.test("Symbol.toPrimitive sees the hint", () => {
const tagged = {
[Symbol.toPrimitive](hint: string) {
return `hint=${hint}`;
},
};
assertEquals(`${tagged}`, "hint=string");
assertEquals(String(tagged), "hint=string");
assertEquals((tagged as unknown as string) + "", "hint=default");
});
Symbol.toPrimitive sees the hint ... ok (0ms)

Read the hints off the results.

  1. A template literal and String() both delivered "string".
  2. + delivered "default", confirming what the previous step inferred from the 6.
  3. Symbol.toPrimitive took precedence: valueOf and toString were never consulted, because this hook outranks both.

This is the right hook when you build a value type, a money or duration class, that should behave sensibly in text and in arithmetic. For anything that matters, also give it named methods, because a reader finds total.format() clearer than a conversion that happens invisibly.

a Date answers the default hint with text

One built-in type takes the other branch of the "default" hint, and it is the reason the hint exists as a separate case at all:

Deno.test("a Date answers the default hint with text", () => {
const epoch = new Date(0);
assertEquals(typeof ((epoch as unknown as number) + 1), "string");
assertEquals(typeof ((epoch as unknown as number) * 1), "number");
assertEquals(Number(epoch), 0);
assertEquals(epoch.getTime(), 0);
});
a Date answers the default hint with text ... ok (0ms)

The same date, three operators, two types.

  1. epoch + 1 passes the "default" hint, and a Date answers "default" with text, so the result is a human-readable date string with a 1 appended to the end.
  2. epoch * 1 is unambiguously numeric, so the hint is "number" and the result is the timestamp 0.
  3. Number(epoch) asks for the number directly and gets the same 0, and epoch.getTime() says the same thing with a name.

Write date.getTime() and skip the whole question.

Object wraps, and a primitive is an instance of nothing

The fourth conversion function from the first step is due its explanation. Object(x) converts a value to an object, and for a primitive that means wrapping it, the same wrappers the values-and-references page warned about in a wrapper object is not its primitive:

Deno.test("Object wraps, and a primitive is an instance of nothing", () => {
const wrapped = Object(true);
assertEquals(typeof wrapped, "object");
assert(wrapped instanceof Boolean);
assertEquals(wrapped.valueOf(), true);

assertFalse((true as unknown) instanceof Boolean);
assertFalse((123 as unknown) instanceof Object);

assertEquals(JSON.stringify(Object(null)), "{}");
assertEquals(JSON.stringify(Object(undefined)), "{}");
});
Object wraps, and a primitive is an instance of nothing ... ok (0ms)

Three facts worth keeping.

  1. Object(true) is an object, an instance of Boolean, and valueOf() unwraps it back to true. Being an object, it is truthy even though it wraps false's sibling, which the truthiness page proved in a wrapper object is truthy even around false.
  2. The primitive true is an instance of nothing, and neither is 123, not even of Object. instanceof is a question for objects; typeof is the question for primitives.
  3. Object(null) and Object(undefined) produce a plain empty object, the only sensible answer for the two kinds of nothing.

Called without new, String, Number, and Boolean are the conversions from the top of this page, and that is the only way to use them. Constructing a wrapper deliberately is never the right move.

primitives have no properties to set

Reading a property on a primitive works, because the language wraps it for the duration of the call. Predict what writing one does:

Deno.test("primitives have no properties to set", () => {
const name = "oak";
assertEquals(name.length, 3);
name.length = 1;
});
Check programs/conversion-and-coercion.test.ts
TS2540 [ERROR]: Cannot assign to 'length' because it is a read-only property.
name.length = 1;
~~~~~~
at file:///programs/conversion-and-coercion.test.ts:94:10

error: Type checking failed.

info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.

The checker refuses on paper, the same shape of evidence as the frozen object in the values-and-references page's Object.freeze stops mutation at one level. And as there, JavaScript stands behind the diagnostic with its own runtime TypeError, which the cast reveals:

Deno.test("primitives have no properties to set", () => {
const name = "oak";
assertEquals(name.length, 3);
assertThrows(
() => {
(name as any).length = 1;
},
TypeError,
"read only property",
);
assertEquals(name, "oak");
});
primitives have no properties to set ... ok (0ms)

The chain, one link at a time.

  1. name.length reads 3, because the read wraps the string in a temporary object that can answer.
  2. The write throws a TypeError in a module, where code is always strict, because there is no persistent object to write into. The wrapper that served the read is already gone.
  3. name is still "oak". Both systems, the checker on paper and the runtime in motion, are stating the fact that a string method returns a new string established on the values-and-references page: primitives are immutable.

the standard library coerces its arguments too

Built-in functions convert their arguments the same way operators do, which explains results that otherwise look like bugs. Hand parseInt a number and watch the journey:

Deno.test("the standard library coerces its arguments too", () => {
assertEquals(Number.parseInt(123.45 as unknown as string), 123);
assertEquals(Number.parseInt("123.45"), 123);
assertEquals(Math.trunc(123.45), 123);
});
the standard library coerces its arguments too ... ok (0ms)

The first line looks like rounding and is not.

  1. parseInt wants text, so it converts the number 123.45 to the string "123.45" first.
  2. Then it parses that string the way the numbers page's three parsers, three sets of rules described: read digits from the front, stop at the first character that is not one. The dot stops it, and the digits after were never read at all.
  3. The second line makes the journey visible by starting from the string, and the answer is identical. Nothing rounded either time.

When you want a whole number from a number, say so: Math.trunc or Math.round, whose difference below zero is the numbers page's floor, ceil, round, and trunc disagree below zero.

In practice