JavaScript Operators and Type Coercion

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Node.js 22 LTS

JavaScript comparison bugs are rarely about the operators being hard. They happen because coercion runs when you are not looking. Learning the handful of rules that matter removes most of the surprises.

== versus ===

1 == "1"      // true   (converted, then compared)
1 === "1"     // false  (different types, no conversion)
  • == is loose equality: if the types differ, one side is converted first.
  • === is strict equality: different types are simply not equal.

Use === by default. The one idiom worth keeping is value == null, which is true for both null and undefined and nothing else.

The loose rules are not worth memorizing; recognize the results:

"" == 0            // true
"0" == 0           // true
false == "0"       // true
null == 0          // false
null == undefined  // true
[] == false        // true

[] == false alone should be enough reason to avoid ==.

Truthy and falsy

Wherever a boolean is expected (if, &&, ||, !), the value is converted. Only these eight values are falsy:

false, 0, -0, 0n, "", null, undefined, NaN

Everything else is truthy, including the counter-intuitive ones:

if ("0") console.log("runs");    // non-empty string
if ([]) console.log("runs");     // empty array
if ({}) console.log("runs");     // empty object
if (" ") console.log("runs");    // a space is not an empty string

So if (str) is the wrong test for “did the user type something”: "0" passes. Use if (str.length > 0) when length is what you mean.

Arithmetic coercion

"3" - 1      // 2      minus is numeric only
"3" + 1      // "31"   plus prefers string concatenation
3 + true     // 4      true becomes 1
"6" * "2"    // 12     *, /, % always go numeric
10 / "a"     // NaN

The asymmetry between + and - is the source of many “why is it a string” bugs. If a value comes from a form field, convert it explicitly before arithmetic.

NaN

NaN means “the result of a numeric operation was not a valid number”. It is not equal to anything, including itself:

NaN === NaN            // false
Number.isNaN(NaN)      // true    <- use this
isNaN("abc")           // true    coerces first, misleading
Number.isNaN("abc")    // false   only true NaN passes

Number.isNaN() does not coerce, which is exactly why it is safe. For parsing, Number.isFinite(value) is often a better guard: it rejects NaN, Infinity, and non-numbers.

Logical operators return operands

&& and || do not return booleans; they return whichever operand decided the result:

const a = 0 || "default";      // "default"
const b = "value" || "default"; // "value"
const c = null && "never";     // null

This is what makes || usable for defaults and dangerous for numeric settings.

?? versus ||

Both set a fallback, but they test different things:

const count = 0;
count || 10      // 10   0 is falsy, replaced
count ?? 10      // 0    0 is a valid value, kept

const text = "";
text || "anonymous"   // "anonymous"
text ?? "anonymous"   // ""

?? (nullish coalescing) falls back only for null and undefined; || falls back for every falsy value.

Use ?? for configuration and defaults, so 0, "", and false survive:

function greet(name) {
  const who = name ?? "anonymous";
  return `Hello, ${who}`;
}

Mixing without parentheses is a syntax error, because the intent is ambiguous:

a ?? b || c      // SyntaxError
(a ?? b) || c    // valid

Optional chaining and nullish assignment

Deep property access throws when an intermediate step is null or undefined:

const city = user.address.city;      // TypeError when address is missing
const city = user?.address?.city;    // undefined instead

?. short-circuits the whole chain; it does not create the missing objects.

??= assigns only when the left side is nullish:

const config = { retries: 0 };
config.retries ??= 3;     // still 0
config.timeout ??= 5000;  // becomes 5000

There are matching ||= and &&=, but they are easier to misuse; ??= is the one you will want most.

Explicit conversion

Relying on coercion hurts readability. Convert on purpose:

Number("42")          // 42
Number("42abc")       // NaN
parseInt("42px", 10)  // 42 — ignores trailing junk
String(42)            // "42"
Boolean("")           // false

Notes:

  • parseInt needs the radix (10), otherwise legacy inputs like "08" are ambiguous.
  • Number("") is 0, while Number(" ") is also 0. Validate empty input before converting.
  • JSON.parse does not coerce: "42" parsed from JSON stays a string.

Common mistakes

1. Comparing with ==

"" == 0 is true. Use ===.

2. if (value) for numbers

0 is falsy and gets skipped. Test value !== undefined when existence is the question.

3. Treating NaN as “just falsy”

NaN usually signals an upstream calculation failure. Fix the source rather than defaulting it away.

4. || wiping out 0 and ""

Use ??, and write a test with 0 as input.

5. Optional chaining without handling undefined

user?.name.toUpperCase() still throws when name is missing. Chain all the way, or check the result.

6. Relying on + for numeric strings

"1" + 1 is "11". Convert first: Number(a) + Number(b).

Summary

  • === for comparison, ?? for defaults, ?. for deep access.
  • Eight falsy values; [], {}, "0", and " " are truthy.
  • && and || return operands, not booleans.
  • Number.isNaN() for NaN.
  • Convert types explicitly at the boundary where data enters the program.

Next: conditionals, loops, and which iteration form to pick.