Equality, truthiness, null and undefined
- Predict
===,==andObject.isresults using the few coercion rules worth knowing, and test forNaNcorrectly - Recite the falsy values and fix truthiness bugs:
{count && …}rendering0, an empty array being truthy,filter(Boolean)dropping zeros - Explain that objects and arrays compare by identity, and compare by an id or specific fields instead
- Model absence in TypeScript —
?:vs| undefinedvs| nullunderstrictNullChecks— and predict what JSON does to each
Read
Three kinds of "equal"
FlagJavaScript has three everyday ways to ask "are these the same?", and they disagree on a handful of values:
=== (strict) | == (loose) | Object.is | |
|---|---|---|---|
| Converts types first? | No | Yes | No |
1 vs "1" | false | true | false |
null vs undefined | false | true | false |
NaN vs NaN | false | false | true |
0 vs -0 | true | true | false |
{} vs {} | false | false | false |
The working rule is short:
- Use
===and!==. No conversions, so what you see is what's compared. x == nullis the one==you'll see on purpose. Loose equality treatsnullandundefinedas equal to each other and to nothing else, sox == nullmeans "xisnullorundefined" in one check.NaNis never===anything, itself included. Test for it withNumber.isNaN(x).- Objects and arrays compare by identity under all three: two separately created objects are never equal, however alike they look.
"1" === 1; // false
"1" == 1; // true — the string was converted to a number
value == null; // true for null and undefined, false for 0, "", false
Number.isNaN(0 / 0); // true
[1, 2] === [1, 2]; // false — two different arrays
Object.is is === with two differences: NaN equals itself, and 0 and -0 are different.
You rarely call it yourself, but React uses it to decide whether state or a dependency changed,
which is why "same contents, new object" counts as a change there.
The coercions worth knowing (so you can read old code)
FlagYou won't write == on purpose (apart from == null), but you will read it, and you will debug
code that relied on it. MDN summarises the loose-equality algorithm in four steps; these are the
ones that explain nearly every surprise:
- Same type → compare like
===."a" == "a",NaN == NaNis still false. nullandundefinedequal each other, and nothing else. Sonull == 0is false, andundefined == ""is false.- An object against a primitive → the object becomes a primitive first. An array becomes its
joined string:
[1, 2] == "1,2"is true,[] == ""is true. - A boolean against anything else → the boolean becomes a number first (
true→ 1,false→ 0). Then strings against numbers → the string becomes a number.
Walk through the famous ones with those rules:
"0" == false; // false → 0, then "0" → 0: true
[] == false; // false → 0, [] → "", "" → 0: true
"2" == true; // true → 1, "2" → 2: false (!)
"true" == true; // true → 1, "true" → NaN: false
null == 0; // rule 2: null only equals undefined: false
"2" == true being false is the one that breaks code like if (flag == true) when flag came
from a query string. With ===, none of these questions arise — the types differ, so the answer
is false.
Relational operators coerce differently
<, >, <=, >= don't have rule 2. Unless both sides are strings, they convert both sides to
numbers, and null becomes 0:
null == 0; // false (equality's special case)
null >= 0; // true (null → 0, and 0 >= 0)
null > 0; // false
"10" < "9"; // true (two strings compare character by character, not as numbers)
So if (stock >= 0) passes for a stock of null. Check for missing values explicitly before
comparing sizes.
NaN needs its own test
NaN is the only value not equal to itself, so x === NaN is always false. Use
Number.isNaN(x). The older global isNaN(x) converts first, so isNaN("abc") is true —
fine for "would this parse?", wrong for "is this the NaN value?".
Collections don't agree either: includes uses SameValueZero (finds NaN), while indexOf uses
=== (never finds it):
[NaN].includes(NaN); // true
[NaN].indexOf(NaN); // -1
null vs undefined in TypeScript — and what JSON does to each
FlagJavaScript has two "no value" values, and TypeScript can tell them apart — only with
strictNullChecks on. It's part of strict, and strict is true by default since TS 6.0
(earlier versions default to off, so check older projects' tsconfig.json). With it off, null
and undefined are assignable to every type and the checks below don't exist.
Three ways to write "maybe absent"
type User = {
middleName?: string; // the key may be missing entirely
nickname: string | undefined; // the key must be present; its value may be undefined
deletedAt: string | null; // always present; null means "no value", on purpose
};
?:— optional.{}is a valid object here. Reading it givesstring | undefined, and TypeScript makes you check:u.middleName[0]is an error,'u.middleName' is possibly 'undefined'.| undefinedwithout?— the key is required: leavenicknameout and TypeScript reports it missing, even thoughundefinedis an allowed value. Rarely what you want for data, sometimes right for a function's return shape.| null— the shape APIs and databases use: the field is always sent, andnullsays "known to be empty". A nullable SQL column typically comes back asnull, not as a missing key.
A useful convention that matches JSON: undefined = not provided, null = explicitly empty.
When you need to accept both, x == null (or x ?? fallback) covers them in one go.
JSON keeps null and drops undefined
JSON.stringify({ nickname: undefined }); // '{}'
JSON.stringify({ nickname: null }); // '{"nickname":null}'
JSON.stringify([undefined, 1]); // '[null,1]' — in arrays it becomes null
That difference is an API contract. A PATCH /users/1 with { nickname: undefined } sends {}
— "change nothing" — while { nickname: null } says "clear the nickname". If your client code
writes undefined to mean "clear", the server never hears it. And on the way back in, a field
the server sends as null will never be undefined, so a check like if (user.deletedAt === undefined) is always false.
Quiz
Which comparison is true?
Which of these values make if (value) { … } run its body? Select all that apply.
{unread && <Badge count={unread} />} shows a stray 0 in the header when there are no unread messages. Why?
selected.includes({ id: 3 }) is false, though selected holds an item with id: 3. What's the right fix?
For which pairs do Object.is(a, b) and a === b give different answers? Select all that apply.
To clear a user's nickname you send PATCH /users/1 with body: JSON.stringify({ nickname: undefined }). What does the server receive?
In a project with strict: true (the default since TS 6.0), what does TypeScript do with function initial(u: { middle?: string }) { return u.middle[0]; }?
Fill in the blank
Return early when value is null or undefined, in a single comparison — and only then (keep 0 and "").
if (value null) return fallback;
Reject a price that failed to parse (Number("abc") is NaN).
if (Number.(price)) throw new Error("price is not a number");
Render the badge only when there are unread messages — and never render a stray 0.
{unread 0 && <Badge count={unread} />}
Is item already in selected? Both hold separately fetched objects.
const isSelected = selected.((s) => s. === item.id);
Type a field the API always sends, using null when there is no value.
type User = { id: string; deletedAt: string };
Predict the output
console.log([1, 2] === [1, 2], [1, 2] == "1,2", [] == false);
const scores = [0, 7, null, 3, undefined]; console.log(scores.filter(Boolean));
console.log(JSON.stringify({ a: undefined, b: null, c: [undefined, 1] }));const values = [NaN, 0]; console.log(values.includes(NaN), values.indexOf(NaN), values.includes(-0));
Further reading
- MDN: Equality comparisons and sameness
- MDN: Equality (==)
- MDN: Greater than or equal (>=)
- MDN: Falsy
- MDN: JSON.stringify()
- React docs: Conditional Rendering
- React docs: useState
- TypeScript Handbook: Everyday Types (null and undefined)
- TypeScript 6.0 release notes (strict by default)
- TypeScript 5.5 release notes: inferred type predicates