Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 01 / 18 25 MIN

The language and the runtime

Values, Types, and Coercion

JavaScript has seven primitive types and one object type. The coercion rules between them cause bugs a type checker never sees. Here is what the operators really do.

A form submits a quantity of "3" and your code adds shipping as a number. The result is "31", the courier rejects it, and nothing threw. That is coercion: the language converting a value from one type to another because the operator asked for a particular shape, and doing it silently. Most of the time it helps. The times it does not help are the ones that reach production.

The reason this feels arbitrary is that nobody was taught the rule. They were taught to "always use ===", which is good advice attached to no explanation, so it fails the moment loose equality is genuinely the right tool or the moment a comparison happens somewhere they did not write.

What follows is the actual rule set, in the order the runtime applies it. Everything here is behaviour you can reproduce in a file and run.

Seven primitive types, and one that swallows everything else

A JavaScript value is either a primitive or an object. There are seven primitives: number, bigint, string, boolean, undefined, symbol and null. Everything else is an object, including arrays, functions, dates, maps and regular expressions.

Primitives are immutable and compared by value. Objects are compared by reference, which means two objects with identical contents are different values:

const a = {};
const b = {};
console.log(a === b);        // false — two different objects
console.log([] === []);      // false
console.log(typeof a);       // object

That last line is why "is this an object or an array" cannot be answered with typeof. The runtime has Array.isArray() for exactly this problem, and it is the only reliable answer:

console.log(Array.isArray([]));   // true
console.log(Array.isArray({}));   // false
console.log(typeof []);           // object

The seventh primitive is the one people forget, and the eighth type is number's larger sibling:

console.log(typeof 1);            // number
console.log(typeof 1n);           // bigint
console.log(typeof "x");          // string
console.log(typeof true);         // boolean
console.log(typeof undefined);    // undefined
console.log(typeof Symbol("s"));  // symbol
console.log(typeof {});           // object
console.log(typeof null);         // object

bigint arrived for integers that exceed Number.MAX_SAFE_INTEGER, which is 9007199254740991 on every engine I have run it on. You cannot mix the two types in arithmetic. 1n + 2n is 3n, and 1n + 2 throws TypeError: Cannot mix BigInt and other types, use explicit conversions. The runtime refuses to guess which type you meant, and it is right to refuse.

typeof is a useful approximation, not a type system

typeof null returning "object" is one of the oldest surviving bugs in the language. It has been that way since the first implementation and it cannot be fixed without breaking code that depends on it. The consequence is that typeof alone cannot distinguish an object from a null, and both are exactly the values that produce the most annoying runtime errors:

const city = undefined;
city.trim();
// TypeError: Cannot read properties of undefined (reading 'trim')

To test for null, be explicit: value === null. To test for "null or undefined", value == null is the one place loose equality is the correct tool, because it is defined to be true for those two values and nothing else. More on that below.

The second quirk is typeof function () {} returning "function". Functions are objects underneath, and there is no function type in the language, but the typeof result is kept for historical reasons. Classes and generator functions report the same way:

console.log(typeof function () {});   // function
console.log(typeof (() => {}));        // function
console.log(typeof class Point {});   // function
console.log(typeof function* () {});  // function

One genuinely useful property of typeof: it never throws for a name that does not exist. Reading an undeclared identifier is a ReferenceError; asking for its type is not. That is why the "is this global defined" check is written typeof window !== "undefined". A later lesson shows the same check failing for a different kind of binding.

Loose equality coerces, strict equality does not

=== compares type first. If the two values have different types, the answer is false and no conversion happens. == runs the abstract equality algorithm, which converts one side until the types match. That algorithm has rules, and the rules are learnable:

  1. null and undefined are equal to each other and to nothing else.
  2. If a number is compared with a string, Number() is applied to the string first.
  3. If a boolean is involved, it is converted to a number before anything else.
  4. If an object is compared with a primitive, the object is converted to a primitive, usually through toString().

Run the cases that actually appear in real code, and the pattern is visible:

console.log(0 == '');              // true   — '' becomes 0
console.log(0 == false);           // true   — false becomes 0
console.log('' == false);          // true   — both become 0
console.log(null == undefined);    // true   — the one useful case
console.log(null == 0);            // false  — null only equals undefined
console.log([] == false);          // true   — [] converts to '' then to 0
console.log([] == '');             // true
console.log([0] == false);         // true   — [0] converts to '0' then to 0
console.log('0' == false);         // true

console.log(0 === '');             // false  — different types
console.log(null === undefined);   // false  — different types

The rule I follow: use === everywhere, with one exception that is worth memorising because it saves a lot of noise. value == null is true for exactly null and undefined, so it is the correct way to ask "was this argument supplied". Writing value === null || value === undefined is the same test with more places to make a typo.

Comparison==Why
null vs undefinedtrueDefined that way on purpose
null vs 0 or ''falsenull equals only undefined
NaN vs anythingfalseIncluding NaN itself
0 vs ''trueString converts to a number
Anything vs falsesee abovefalse converts to 0 first
Two objects, same contentsfalseObjects compare by reference

The fifth row is the one that ruins code reviews. if (list == false) does not ask whether the list is empty. It asks whether list converts to the number 0, and an empty array does.

Falsy means eight values, and empty containers are not among them

Everywhere a boolean is expected, the runtime applies Boolean(). Exactly eight values convert to false:

const falsy = [false, 0, -0, 0n, '', null, undefined, NaN];
for (const value of falsy) {
  console.log(String(value).padEnd(10), Boolean(value));
}
false      false
0          false
0          false
0          false
           false
null       false
undefined  false
NaN        false

Everything else is truthy, and that includes the two containers everyone gets wrong:

console.log(Boolean([]));            // true — an empty array is truthy
console.log(Boolean({}));            // true — an empty object is truthy
console.log([].length === 0);        // true — this is the check you wanted

So if (items) is not an emptiness check, it is an existence check. The correct test for an empty array is items.length === 0, and for an object, Object.keys(obj).length === 0. The same trap appears in the opposite direction with 0: a size or a count of zero is falsy, so const size = options.size || 10 silently replaces a legitimate zero with ten. Use options.size ?? 10 when zero should survive.

NaN is not equal to itself, and that is the point

NaN means "this value is not a valid number", and it arrives from operations that have no numeric answer: Number("abc"), 0 / 0, Math.sqrt(-1), parseFloat("twelve"). What makes it strange is that it is not equal to anything, including itself:

console.log(NaN === NaN);            // false
console.log(NaN == NaN);             // false
console.log(Object.is(NaN, NaN));    // true

The reason is that NaN is not a value in the usual sense, it is a category of failure. IEEE 754 says that comparing two failing results tells you nothing, so every comparison with NaN is false. The practical consequence is that value === NaN never detects a bad number, and the code that "worked" did so because the bad number never arrived.

The test you want is Number.isNaN(value). The global isNaN(value) is a different and worse function: it coerces its argument first, so it reports true for strings that are not numbers at all.

console.log(Number.isNaN('abc'));   // false — 'abc' is a string, not NaN
console.log(isNaN('abc'));          // true  — 'abc' coerces to NaN
console.log(Number.isNaN(NaN));     // true

Prefer Number.isNaN, and remember that the reason a float check reached the comparison at all is usually upstream. A NaN in a price is a parsing problem, not a rendering problem.

0.1 + 0.2 is not 0.3, and the fix is not toFixed

JavaScript numbers are IEEE 754 double-precision floats. They store a binary fraction, and 0.1 has no exact binary representation, so the value you type is already approximate before any arithmetic happens:

console.log(0.1 + 0.2);                        // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);                // false
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);  // true

The first instinct is to round with toFixed(2), and that fixes the display without fixing the comparison:

const sum = 0.1 + 0.2;
console.log(sum.toFixed(2));            // '0.30' — a string, ready for display
console.log(sum === 0.3);               // false  — the value was never rounded
console.log(Number((0.1 + 0.3).toFixed(2)) === 0.4);   // true

So the third line passing proves nothing about the second. Rounding a float to a string and converting it back recovers a different double, and whether that double matches your intended value depends on the inputs. A worse case is a price in dollars converted to cents: 1.005 * 100 is 100.49999999999999, so Math.round(1.005 * 100) is 100 when the arithmetic you intended gives 101. toFixed is for showing a number to a person. It is not a correctness tool, and Math.round on a scaled float inherits the error rather than removing it.

What actually works depends on what the number is for. For money, stop using fractional units. Store cents as integers, do all arithmetic in cents, and divide only when you display. 1999 + 799 is exact; 19.99 + 7.99 is not. For physics or measurement, compare with a tolerance, which is what the Number.EPSILON line above does. Number.EPSILON is the distance between 1 and the next representable number, so it is a fair tolerance near 1 and a poor one at a scale of millions. Pick the tolerance from the data, not from the constant.

Two more bounds are worth knowing. Integers above Number.MAX_SAFE_INTEGER are approximated: the literal 9007199254740993 prints as 9007199254740992. Above roughly 1e21, String() switches to exponential notation and the digits you did not supply appear. Both are reasons to keep identifiers out of number entirely.

Lab 20 minutes

Write a function quantity(input) that accepts whatever a form or a query string hands it and returns a usable whole number of items, or null when the input cannot be read as a quantity.

It must handle all of these, and you should know what each one returns before you run it: "3", 3, "", " ", null, undefined, "12px", NaN, 0, "0", -2, 2.7.

Then answer two questions in writing. Should 2.7 round or be rejected, and who should decide? And which falsy values in that list are genuinely "no quantity given" rather than "a quantity of zero"?

Show the solution and the reasoning

The interesting decisions are about 0, "" and 2.7. A first attempt usually reaches for || or a loose comparison and gets at least one of them wrong.

function quantity(input) {
  if (input === null || input === undefined) return null;
  if (typeof input === 'boolean') return null;

  const text = String(input).trim();
  if (text === '') return null;

  const value = Number(text);
  if (!Number.isFinite(value)) return null;

  const whole = Math.trunc(value);
  if (whole < 0) return null;

  return whole;
}

const inputs = ['3', 3, '', '  ', null, undefined, '12px', NaN, 0, '0', -2, 2.7];
for (const input of inputs) {
  console.log(String(input).padEnd(12), quantity(input));
}
3            3
3            3
             null
             null
null         null
undefined    null
12px         null
NaN          null
0            0
0            0
-2           null
2.7          2

Number(text) rather than parseInt is the first decision. parseInt('12px', 10) returns 12, because it reads digits until something else appears. A value that carries a unit is not a quantity, it is a string that starts with digits, and treating it as valid means a broken input becomes a silently wrong order. Number('12px') is NaN, and NaN is the honest answer.

The emptiness check happens before Number() because Number('') and Number(' ') are both 0. Without the text === '' line, a blank field becomes an order for zero items, which is different from "the field was left alone". That is also the answer to the second question: '', ' ', null and undefined mean no quantity was given, while 0 and '0' are a real quantity of zero and must survive. This is the case that input || 1 gets wrong, because 0 || 1 is 1.

Number.isFinite covers NaN and both infinities in one test, and it does not coerce, so it is the right companion to Number.isNaN when you want "a real, finite number".

Rounding with Math.trunc is the choice I would defend for a warehouse quantity and reject for a measurement. It matches the "whole units" contract exactly, so 2.7 becomes 2 and nothing is hidden. Math.round would turn 2.7 into 3, which quietly grants the customer an item they did not order. If the business rule is that a fractional quantity is invalid, use Number.isInteger(value) and return null instead. Either is defensible; deciding by accident is not.

The typeof input === 'boolean' guard is there because Number(true) is 1 and Number(false) is 0. Checkbox state arriving where a quantity was expected is a real integration bug, and the coercion would accept it without complaint.

Progress

Mark this lesson done and it stays marked in this browser.