JavaScript Type Coercion and Equality: Why `[] == ![]` is `true`
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 string —
String(x)or the implicit conversion done by+when either side is a string. - To number —
Number(x)or the implicit conversion done by-,*,/,%,==,<,>,<=,>=. - To boolean —
Boolean(x)or the implicit conversion inif,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:
- If types match, compare directly (like
===). null == undefinedistrue(and onlynull/undefinedare loosely equal to each other).- Number vs string → coerce string to number.
- Boolean vs anything → coerce boolean to number.
- Object vs primitive → call
ToPrimitiveon the object, then re-compare. - 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:
[] == ![]
![]— the operand is an array (truthy), so![]isfalse.- Now we have
[] == false. - Rule 4: boolean on the right → coerce to number.
false→0. Now[] == 0. - Rule 5: object on the left →
ToPrimitive([])→''. Now'' == 0. - Rule 3: string vs number → coerce string to number.
Number('')is0. Now0 == 0. - 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 parseInt — parseInt('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:
== nullfor 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.ishandlesNaNand-0correctly when===does not.- Objects compare by reference, not value. Deep equality needs a helper.
- Be explicit:
Number(x),String(x),Boolean(x),+x,!!xare 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
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


