JavaScript Type Coercion and Equality: Why `[] == ![]` is `true`

Link copied
JavaScript Type Coercion and Equality: Why `[] == ![]` is `true`

JavaScript Type Coercion and Equality: Why `[] == ![]` is `true`

JS Tutorial Module 6: Modern JS Lesson 6.3

JavaScript's type coercion is the source of every "WAT?" tweet and every conference joke. But it is not arbitrary chaos — it follows a small set of rules that, once you know them, make every weird-looking comparison predictable.

This article unpacks those rules: == vs ===, falsy/truthy semantics, what coercion actually does behind the scenes, and the production gotchas that cost real money.

This is Part 6 of the JavaScript Fundamentals series.

Coercion = automatic type conversion #

When an operator wants one type but receives another, JavaScript silently converts the operand. That conversion is type coercion.

Three kinds:

  • To stringString(x) or the implicit conversion done by + when either side is a string.
  • To numberNumber(x) or the implicit conversion done by -, *, /, %, ==, <, >, <=, >=.
  • To booleanBoolean(x) or the implicit conversion in if, while, &&, ||, !, ternary.

Understanding coercion = knowing what each operator forces, and how each primitive maps when coerced.

Truthy and falsy #

In boolean contexts (if, &&, ||, etc.), every value is treated as either truthy or falsy. There are exactly seven falsy values:

false
0
-0
0n           // BigInt zero
''           // empty string
null
undefined
NaN

Everything else is truthy. Including the gotchas:

Boolean([]);        // true — empty array is truthy!
Boolean({});        // true — empty object is truthy!
Boolean('false');   // true — non-empty string
Boolean('0');       // true — non-empty string
Boolean(' ');       // true — whitespace string

This is why you can write:

if (user) { ... }   // checks for null/undefined/0/''
if (!list.length) { ... }   // empty array? — must check .length

To-number coercion table #

Input Number(x)
'42' 42
' 42 ' 42 (whitespace trimmed)
'' 0
'foo' NaN
true / false 1 / 0
null 0
undefined NaN
[] 0 (empty array → empty string → 0)
[42] 42 (one-element array → its string form → number)
[1, 2] NaN ('1,2' → NaN)
{} NaN

The array/object behavior is the source of most coercion jokes. It goes through ToPrimitive, which calls valueOf() then toString(). [].toString() is '', and Number('') is 0.

To-string coercion table #

Input String(x)
42 '42'
true / false 'true' / 'false'
null 'null'
undefined 'undefined'
[] ''
[1, 2] '1,2'
{} '[object Object]'
{ toString: () => 'hi' } 'hi'

Notice + does string coercion if either operand is a string:

1 + '1';   // '11'   — number coerced to string
'1' + 1;   // '11'   — same
1 + 2 + '3'; // '33' — left to right: 3 + '3'

Numeric operators (-, *, /, %) coerce to number both sides:

'10' - 5;   // 5
'10' * 2;   // 20
'10' / '2'; // 5

== vs === #

  • === (strict equality) — no coercion. Types must match. Use this 95% of the time.
  • == (loose equality) — coerces. Same rules below.

== coercion rules in order:

  1. If types match, compare directly (like ===).
  2. null == undefined is true (and only null/undefined are loosely equal to each other).
  3. Number vs string → coerce string to number.
  4. Boolean vs anything → coerce boolean to number.
  5. Object vs primitive → call ToPrimitive on the object, then re-compare.
  6. Otherwise false.

From these rules, all the famous weirdness follows.

Walking through [] == ![] #

This is the most-quoted JS quiz question. Let's walk it step by step:

[] == ![]
  1. ![] — the operand is an array (truthy), so ![] is false.
  2. Now we have [] == false.
  3. Rule 4: boolean on the right → coerce to number. false0. Now [] == 0.
  4. Rule 5: object on the left → ToPrimitive([])''. Now '' == 0.
  5. Rule 3: string vs number → coerce string to number. Number('') is 0. Now 0 == 0.
  6. Same type, equal → true.

Weird? Yes. Arbitrary? No. Every step follows a rule. The lesson is: never use ==, and the weird stops happening.

When == is genuinely useful #

There is one case where == is the right tool: checking for both null and undefined in a single comparison.

if (value == null) { ... }   // matches null AND undefined

vs the strict-equality version:

if (value === null || value === undefined) { ... }

Both are correct; == null is shorter and idiomatic. The ESLint rule eqeqeq has a null exception to allow this exact pattern.

More gotchas decoded #

0 == '0';            // true  (string → number)
0 == [];             // true  ([] → '' → 0)
0 == '0';            // true
'0' == [];           // false ('0' vs '' — same type now, not equal)
'' == false;         // true
'1' == true;         // true  (both → 1)
'2' == true;         // false (true → 1, '2' → 2, 1 !== 2)
null == 0;           // false (rule 2 isolates null/undefined)
null == undefined;   // true
NaN == NaN;          // false (NaN is never equal to anything, even itself)

The NaN one is so important it has its own check: Number.isNaN(x) (or the older isNaN(x) which is buggier).

Equality with objects #

Objects are compared by reference, not value, in both == and ===:

{} === {};                // false — two different objects
const a = {}; a === a;    // true  — same reference
[] == [];                 // false

For deep equality, you need a helper:

import { isEqual } from 'lodash-es';
isEqual({ a: 1 }, { a: 1 }); // true

Or JSON.stringify for simple cases (with caveats — order matters, no functions, no circular refs).

We have a detailed post on mutability and immutability that covers the reference-vs-value model in depth.

Object.is: a third equality #

Introduced in ES2015, Object.is(a, b) is almost the same as === with two exceptions:

Object.is(NaN, NaN);   // true  (=== says false)
Object.is(0, -0);      // false (=== says true)

Useful in: React's useState setter (it uses Object.is to decide whether to skip a re-render), and any deep-equality helper.

Switch statements use ===, not == #

Good news: switch compares with strict equality:

switch (value) {
  case '1':  break; // matches only '1' (string)
  case 1:    break; // matches only 1 (number)
}

No coercion surprises. If you want fall-through across types, do explicit coercion in your switch expression: switch (String(value)) {}.

Coercion in arithmetic #

'5' + 3;       // '53'    (string + → concat)
'5' - 3;       // 2       (- forces number)
'5' * '2';     // 10
'a' - 'b';     // NaN     (Number('a') is NaN)
+'42';         // 42      (unary plus coerces to number)
+'abc';        // NaN
-'5';          // -5
!!'hi';        // true    (!! is the truthy/falsy idiom)

+x is the fastest, most readable way to coerce to number. Number(x) and parseInt(x, 10) have different edge cases — pick deliberately.

parseInt vs Number vs + #

Number('42px');    // NaN  — strict
parseInt('42px');  // 42   — reads digits until non-digit
parseFloat('3.14abc'); // 3.14
+'42';             // 42   — strict, like Number()
Number('');        // 0    — empty string
parseInt('');      // NaN  — surprise!

Useful rule: always pass the radix to parseIntparseInt('08', 10) not parseInt('08'). Without the radix, old engines treat leading-0 strings as octal.

Common gotchas #

Gotcha 1: Empty arrays are truthy but coerce to 0.

if ([]) console.log('truthy'); // logs
[] == 0;                       // true

Use arr.length for emptiness checks: if (arr.length === 0).

Gotcha 2: JSON.parse('null') returns null (not undefined).

JSON.parse('null'); // null

Check with === null if it matters.

Gotcha 3: Number(null) is 0, Number(undefined) is NaN.

Number(null);      // 0
Number(undefined); // NaN

Mixing these in arithmetic gives different results.

Gotcha 4: "".split(',') returns [''], not [].

''.split(',');  // ['']
[].length;      // 0
[''].length;    // 1 — surprise empty-string element

Guard with s ? s.split(',') : [].

The 2026 rule #

  • Use === everywhere.
  • Exception: == null for the null-or-undefined check.
  • Never == for anything else.
  • Be explicit with type conversion: Number(x), String(x), Boolean(x), +x, !!x.
  • Configure ESLint with eqeqeq: ['error', 'always', { null: 'ignore' }] — catches misuse, allows the null pattern.

Follow those four rules and the entire weird-coercion category of bugs disappears from your codebase.

Recap #

  • Coercion is automatic type conversion — to string (+), to number (-, *, etc), to boolean (if, ||, !).
  • Falsy values: false, 0, -0, 0n, '', null, undefined, NaN. Everything else (including [] and {}) is truthy.
  • === compares without coercion. == coerces using a small set of rules. Use ===; == only for == null.
  • Object.is handles NaN and -0 correctly when === does not.
  • Objects compare by reference, not value. Deep equality needs a helper.
  • Be explicit: Number(x), String(x), Boolean(x), +x, !!x are clearer than implicit coercion.

Next up: The Event Loop, Microtasks vs Macrotasks — the model that explains why Promise.then always runs before setTimeout(0), and the seven kinds of tasks a browser juggles.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *