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:
nullandundefinedare equal to each other and to nothing else.- If a number is compared with a string,
Number()is applied to the string first. - If a boolean is involved, it is converted to a number before anything else.
- 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 undefined | true | Defined that way on purpose |
null vs 0 or '' | false | null equals only undefined |
NaN vs anything | false | Including NaN itself |
0 vs '' | true | String converts to a number |
Anything vs false | see above | false converts to 0 first |
| Two objects, same contents | false | Objects 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.
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.