JavaScript & TypeScript Fundamentals
JavaScript & TypeScript Fundamentals

Equality, truthiness, null and undefined

Lesson · ~30 min
After this unit you can
  • Predict ===, == and Object.is results using the few coercion rules worth knowing, and test for NaN correctly
  • Recite the falsy values and fix truthiness bugs: {count && …} rendering 0, 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 | undefined vs | null under strictNullChecks — and predict what JSON does to each

Read

Simple
1 / 2

Three kinds of "equal"

Flag

JavaScript 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?NoYesNo
1 vs "1"falsetruefalse
null vs undefinedfalsetruefalse
NaN vs NaNfalsefalsetrue
0 vs -0truetruefalse
{} vs {}falsefalsefalse

The working rule is short:

  • Use === and !==. No conversions, so what you see is what's compared.
  • x == null is the one == you'll see on purpose. Loose equality treats null and undefined as equal to each other and to nothing else, so x == null means "x is null or undefined" in one check.
  • NaN is never === anything, itself included. Test for it with Number.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.

Medium
1 / 2

The coercions worth knowing (so you can read old code)

Flag

You 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:

  1. Same type → compare like ===. "a" == "a", NaN == NaN is still false.
  2. null and undefined equal each other, and nothing else. So null == 0 is false, and undefined == "" is false.
  3. 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.
  4. 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
Complex
1 / 2

null vs undefined in TypeScript — and what JSON does to each

Flag

JavaScript 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 gives string | undefined, and TypeScript makes you check: u.middleName[0] is an error, 'u.middleName' is possibly 'undefined'.
  • | undefined without ? — the key is required: leave nickname out and TypeScript reports it missing, even though undefined is 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, and null says "known to be empty". A nullable SQL column typically comes back as null, 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?

Flag

Which of these values make if (value) { … } run its body? Select all that apply.

Flag

{unread && <Badge count={unread} />} shows a stray 0 in the header when there are no unread messages. Why?

Flag

selected.includes({ id: 3 }) is false, though selected holds an item with id: 3. What's the right fix?

Flag

For which pairs do Object.is(a, b) and a === b give different answers? Select all that apply.

Flag

To clear a user's nickname you send PATCH /users/1 with body: JSON.stringify({ nickname: undefined }). What does the server receive?

Flag

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]; }?

Flag

Fill in the blank

Return early when value is null or undefined, in a single comparison — and only then (keep 0 and "").

Flag
if (value  null) return fallback;

Reject a price that failed to parse (Number("abc") is NaN).

Flag
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.

Flag
{unread  0 && <Badge count={unread} />}

Is item already in selected? Both hold separately fetched objects.

Flag
const isSelected = selected.((s) => s. === item.id);

Type a field the API always sends, using null when there is no value.

Flag
type User = { id: string; deletedAt: string  };

Predict the output

console.log([1, 2] === [1, 2], [1, 2] == "1,2", [] == false);
Flag
const scores = [0, 7, null, 3, undefined];
console.log(scores.filter(Boolean));
Flag
console.log(JSON.stringify({ a: undefined, b: null, c: [undefined, 1] }));
Flag
const values = [NaN, 0];
console.log(values.includes(NaN), values.indexOf(NaN), values.includes(-0));
Flag

Further reading