JavaScript Data Types and Coercion: ==, ===, Truthiness, and NaN Explained
JavaScriptโs type system has a terrible reputation, mostly earned from conference-talk screenshots: [] + {} is "[object Object]", "5" - 1 is 4, NaN !== NaN. It looks lawless.
It isnโt. Coercion follows a small, fixed rulebook โ weird in places, but mechanical. This guide teaches the rulebook: the eight types, the null/undefined split, truthiness, why === is the default and == is legacy, and the handful of coercion rules that let you predict the screenshots instead of being ambushed by them.
The Eight Types
JavaScript has exactly seven primitive types plus objects:
| Type | Values | Notes |
|---|---|---|
number | 42, 3.14, NaN, Infinity | one numeric type โ all floating point |
string | "hi", 'hi', `hi` | immutable, UTF-16 |
boolean | true, false | |
undefined | undefined | โno value was ever setโ |
null | null | โintentionally emptyโ |
bigint | 42n | integers beyond 2โตยณ; niche but real |
symbol | Symbol("id") | unique keys; mostly library/meta territory |
object | {}, [], functions, datesโฆ | everything else โ including arrays and functions |
Primitives are immutable values compared by value; objects are mutable and compared by reference:
"abc" === "abc" // true โ same value{ a: 1 } === { a: 1 } // false โ two different objects!const x = { a: 1 }, y = x;x === y // true โ same referenceThat object-equality behavior surprises everyone once: there is no built-in deep equality for objects. Two structurally identical objects are not ===. Comparing contents means comparing fields yourself, using a library, or (crudely) JSON.stringify both โ with its ordering caveats.
Notable details in the table: numbers are all doubles โ there is no int type. Integer math is exact up to Number.MAX_SAFE_INTEGER (2โตยณ โ 1 โ 9 quadrillion); beyond that you need BigInt (this bites real systems that put database IDs through JS math). And yes, 0.1 + 0.2 === 0.30000000000000004 โ thatโs IEEE 754 floating point, identical in Java and Python; itโs not a JS quirk, just the one JS gets screenshot-blamed for. Money: work in integer minor units (paise/cents).
typeof is your runtime type probe, with two famous outputs to memorize:
typeof 42 // "number"typeof "hi" // "string"typeof undefined // "undefined"typeof null // "object" โ the unfixable 1995 bug โ null is NOT an objecttypeof [] // "object" โ arrays are objects; use Array.isArray(x)typeof (() => {}) // "function" โ the one non-primitive typeof distinguishesnull vs. undefined: One Distinction, Actually Useful
Two empty values, one convention that makes them coexist sanely:
undefinedโ the systemโs โnothing hereโ: unset variables, missing properties, missing function arguments, functions that return nothing.nullโ the programmerโs โdeliberately nothingโ: you assign it to say โchecked, and the answer is no value.โ
let pending; // undefined โ never setconst user = { name: "Ada" };user.email // undefined โ no such propertyconst match = s.match(/x/); // null โ the API says "searched, found nothing"In your own code: read undefined as โnobody set this,โ write null when you mean โexplicitly empty.โ And when checking for either, modern JS gives you precision tools:
value ?? fallback // nullish coalescing: fallback ONLY for null/undefineduser?.address?.city // optional chaining: short-circuits to undefined at first null/undefinedcount ?? 10 // 0 stays 0! (count || 10 would wrongly replace 0)That last line is the practical difference between ?? and ||: || replaces any falsy value (including 0, "", false โ often legitimate data); ?? replaces only true absence. Since ES2020, ?? + ?. have quietly eliminated an entire genre of defensive-checking boilerplate. Use them.
Truthiness: The Whole List
Conditions in JS accept any value, coercing it to boolean. The falsy list is short enough to memorize completely โ everything not on this list is truthy:
false 0 -0 0n "" null undefined NaNWhich means these are all truthy, and each has caught someone:
if ("false") { } // truthy โ non-empty stringif ([]) { } // truthy โ empty array is an objectif ({}) { } // truthy โ empty object tooif ("0") { } // truthy โ non-empty string!Truthiness is genuinely convenient โ if (items.length), if (errorMessage) โ but it has a classic failure mode: treating legitimate falsy data (0, empty string) as absence. A quantity of 0 or a middle name of "" is data, not nothing:
// bug: user typed 0, code thinks "not provided"const qty = input || 1;// fix:const qty = input ?? 1;The rule of thumb: truthiness for โdoes this collection/string have anythingโ checks; explicit comparisons (=== null, !== undefined, ??) whenever 0/""/false are valid values in your domain.
Coercion: The Rulebook Behind the Screenshots
Implicit conversion happens in three contexts, each with fixed rules:
To string โ the + operator, when either side is a string:
"5" + 1 // "51" โ + prefers concatenation1 + 2 + "3" // "33" โ left-to-right: 3, then "33""" + 42 // "42" โ old-school numberโstring trickTo number โ every other arithmetic operator, comparisons with </>, and unary +:
"5" - 1 // 4 โ minus has no string meaning, so both sides โ number"5" * "2" // 10+"42" // 42 โ unary plus: terse stringโnumber conversion"abc" - 1 // NaN โ "abc" can't become a numbertrue + 1 // 2 โ true โ 1, false โ 0So the infamous asymmetry โ "5" + 1 vs "5" - 1 โ reduces to one sentence: + means concat if any string is present; every other math operator means numbers. Most viral โJS is brokenโ examples are this rule plus the object-to-primitive rule (objects become strings like "[object Object]", arrays join their elements: [1,2] + "" is "1,2"), applied consistently.
Explicit conversion โ prefer it in real code; it documents intent:
Number("42") // 42Number("") // 0 โ surprise worth knowingNumber(null) // 0 โ and thisNumber(undefined) // NaNparseInt("42px") // 42 โ parses a leading number, ignores the restparseFloat("3.9m") // 3.9String(42) // "42"Boolean(x) // truthiness, made explicit (same as !!x)Number converts the whole string strictly; parseInt/parseFloat scavenge a leading number โ pick by whether trailing junk should fail or be ignored. For user input, remember both accept garbage differently: validate before trusting.
NaN: The Value That Isnโt Equal to Itself
NaN (โnot a numberโ โ which is, of course, of type number) results from failed numeric operations: Number("abc"), 0/0, Math.sqrt(-1). Its defining property comes from the IEEE floating-point standard:
NaN === NaN // false โ NaN is not equal to ANYTHING, including itselfNumber.isNaN(NaN) // true โ the correct testNumber.isNaN("abc") // false โ checks "is this actually the NaN value"isNaN("abc") // true โ legacy global: coerces first, misleading; avoidThe practical hazards: NaN silently propagates (any arithmetic touching NaN yields NaN), so one bad parse can turn an entire calculation chain into NaN by the time you notice โ and since NaN is falsy and unequal to everything, it slips through naive checks. Defenses: validate inputs at the boundary (Number.isFinite(x) is often the right gate โ it rejects NaN and Infinity and non-numbers), and when a NaN appears, trace backwards to the earliest operation that could have produced it.
== vs ===: End of Debate
=== (strict equality) compares without coercion: different types โ false, done. == (loose equality) coerces first, following an arcane table:
1 === "1" // false1 == "1" // true โ string coerced to number0 == false // true"" == 0 // truenull == undefined // true โ and == with null matches ONLY undefinednull == 0 // false โ inconsistent with the previous lines' logicNaN == NaN // false โ nothing helps NaNThe verdict, shared by every style guide, linter default, and this series: always === and !==. The single defensible use of == youโll meet in the wild is x == null as an intentional shorthand for โnull or undefinedโ โ recognize it in code review, and prefer x === null || x === undefined or restructuring with ?? when writing.
Comparison has one more trap worth flagging: relational operators on mixed types. "10" < "9" is true (string comparison is lexicographic), while "10" < 9 is false (number coercion). If either side might be a string โ user input, URL params, form fields โ convert explicitly before comparing. Sorting has the same disease: [10, 9, 1].sort() gives [1, 10, 9] because default sort is stringwise; you want sort((a, b) => a - b), detailed in array methods.
Frequently Asked Questions
Why does JavaScript even have coercion? The 1995 design goal: scripts embedded in pages, written by non-programmers, should limp onward rather than crash. Form fields deliver strings; coercion made "5" * quantity work. In 2026 we have better tools (explicit conversion, TypeScript), but the donโt-break-the-web rule means the behavior is permanent โ hence the strategy of knowing it while avoiding relying on it.
Does TypeScript fix all of this? Much of it: TS flags mixed-type comparisons and arithmetic at compile time, which is a big part of its value. But TS types vanish at runtime โ data arriving from APIs, JSON, and users is whatever it is, so boundary validation (and understanding these rules) remains your job.
How do I check types reliably? typeof for primitives (minus the null bug), Array.isArray for arrays, x === null for null, Number.isFinite/Number.isNaN for numeric health, instanceof for class instances (with caveats across frames/realms). There is no one universal type check โ thatโs honest, if annoying.
Whatโs the deal with Object.is? A third equality: like === except Object.is(NaN, NaN) is true and it distinguishes +0/-0. Rarely needed directly, but itโs what Array.prototype.includes uses โ which is why [NaN].includes(NaN) is true while [NaN].indexOf(NaN) is -1. Good trivia; better reason to use includes.
A Boundary-Validation Habit That Replaces Half of This Chapter
The deepest defense against coercion bugs isnโt memorizing rules โ itโs ensuring types are known everywhere except the edges. Data enters JS programs from exactly a few doors: user input (always strings), URL params (strings), JSON APIs (whatever the server sent), storage (strings via localStorage). Convert and validate at the door, once:
function parseQuantity(raw) { const n = Number(raw); if (!Number.isInteger(n) || n < 1 || n > 999) { throw new RangeError(`Quantity must be 1-999, got "${raw}"`); } return n;}Past that function, quantity is a trustworthy integer โ no downstream parseInt sprinkles, no || 1 guards, no wondering whether qty + 1 will concatenate. Every conversion rule in this chapter still applies inside the boundary function; it just applies in one place instead of fifty. This is the pattern TypeScript formalizes (types trusted inside, validation at the perimeter), and adopting it in plain JS makes the eventual TS migration nearly mechanical.
Self-Check
Predict each โ the rulebook covers all of them:
1. "10" - 52. "10" + 53. [] + ""4. 0 ?? 75. 0 || 76. Number("")7. "b" + "a" + +"a" + "s" // a classic โ hint: unary plus8. [3, 20, 100].sort()[0]9. NaN === NaN10. typeof nullAnswers: (1) 5 โ minus means numbers. (2) "105" โ plus with a string concatenates. (3) "" โ array coerces to empty string. (4) 0 โ nullish keeps falsy-but-present values. (5) 7 โ || discards 0. (6) 0 โ empty string โ 0. (7) "baNaNs" โ +"a" is NaN, concatenated. (8) 100 โ stringwise sort puts โ100โ first. (9) false. (10) "object".
Score eight or more and coercion no longer owns you. Next: Functions โ declarations vs arrows, parameters and defaults, and functions-as-values, the idea the entire rest of the language is built on.