There are seven primitive types: string, number, boolean, null,
undefined, symbol, and bigint. Everything that isn't a primitive is an
object — and that includes arrays, functions, dates, and null's
misleading typeof.
typeof 'hi' // 'string'
typeof 42 // 'number'
typeof true // 'boolean'
typeof undefined // 'undefined'
typeof Symbol() // 'symbol'
typeof 10n // 'bigint'
typeof null // 'object' <- historical bug, null IS a primitive
Two defining traits: primitives are immutable (you can't change the value
itself, only rebind the variable) and they're compared by value, whereas
objects are mutable and compared by reference. When you call a method like
'hi'.toUpperCase(), JS temporarily wraps the primitive in an object
(autoboxing) and discards it afterward.
=== is strict equality: it compares value and type with no
conversion — if the types differ, it's immediately false. == is loose
equality: it coerces the operands toward a common type first, which
produces a list of surprising results.
0 === '' // false (number vs string)
0 == '' // true (both coerce to 0)
0 == '0' // true
'' == '0' // false <- not even transitive!
null == undefined // true (special-cased)
NaN == NaN // false
[] == ![] // true (classic "wtf" — [] coerces to '', ![] is false -> 0)
Prefer === virtually always — it's predictable and the coercion rules
behind == are a frequent bug source. The one common idiomatic exception is
x == null, which conveniently matches both null and undefined.
Coercion is JavaScript automatically converting a value from one type to
another. It comes in two flavors: implicit (the engine does it for you
during an operation, e.g. '5' * 2) and explicit (you ask for it,
Number('5'), String(5), Boolean(0)).
The operator that trips everyone up is +: if either side is a string, it
does string concatenation; otherwise it does numeric addition. The other
arithmetic operators (-, *, /) have no string meaning, so they coerce to
numbers.
1 + '2' // '12' (+ with a string -> concatenation)
1 - '2' // -1 (- forces numeric -> 1 - 2)
'5' * '2' // 10 (* forces numeric)
true + 1 // 2 (true -> 1)
[] + {} // '[object Object]' (both -> strings)
In an interview, narrate the rule ("+ prefers strings, the rest prefer
numbers") rather than memorizing every edge case.
There are exactly eight falsy values — memorize the list, because everything else is truthy:
false
0
-0
0n // BigInt zero
'' // empty string
null
undefined
NaN
The famous gotchas are the things people expect to be falsy but aren't:
'0', 'false', [] (empty array), and {} (empty object) are all
truthy.
if ([]) console.log('runs!') // empty array is truthy
if ('0') console.log('runs!') // non-empty string is truthy
Boolean([]) // true
This matters for if conditions, ||/&&, and the nullish coalescing
operator ?? (which, unlike ||, only falls back on null/undefined, not
on 0 or '').
Both represent "no value," but they signal different intents:
undefinedis the language's default "absence" — a declared-but-unassigned variable, a missing object property, a missing function argument, or the return of a function with noreturn. The engine produces it.nullis an intentional, explicit "no value here" that you assign to say "this is deliberately empty."
let a // undefined (never assigned)
const obj = {}
obj.missing // undefined (no such property)
const b = null // null (you chose emptiness)
typeof undefined // 'undefined'
typeof null // 'object' <- long-standing bug, never fixed for compat
null == undefined // true (loose — they're "equal absences")
null === undefined // false (different types)
Use Array.isArray(value). You can't use typeof, because arrays are
objects, so typeof [] returns 'object' — indistinguishable from a plain
object or null.
typeof [] // 'object' <- useless for arrays
Array.isArray([]) // true
Array.isArray({}) // false
Array.isArray('abc') // false
Array.isArray is also more robust than the older
value instanceof Array trick, because instanceof breaks across
realms (e.g. an array coming from an <iframe> has a different Array
constructor, so instanceof returns false). Array.isArray works
regardless of realm.
NaN ("Not-a-Number") is, by the IEEE-754 spec and ECMAScript, the only
value not equal to itself — there are many distinct computations that produce
"not a number," so the standard defines all comparisons with NaN (including
NaN === NaN) as false. You therefore can't detect it with ===.
NaN === NaN // false
0 / 0 // NaN
Number('abc') // NaN
Number.isNaN(NaN) // true reliable
Number.isNaN('abc') // false (no coercion — 'abc' simply isn't NaN)
isNaN('abc') // true misleading: it coerces 'abc' -> NaN first
Use Number.isNaN(value) (ES2015), which checks "is this literally the
NaN value." Avoid the global isNaN, which coerces its argument to a number
first and so reports true for plenty of non-NaN inputs. (Another trick that
works: value !== value is true only for NaN.)
Object.is(a, b) is "same-value" equality. It behaves like === except for
two edge cases: it treats NaN as equal to itself, and it distinguishes +0
from -0.
Object.is(NaN, NaN) // true (=== gives false)
Object.is(0, -0) // false (=== gives true)
Object.is(1, 1) // true
It's the same algorithm React uses to decide whether state/props changed. Use
=== for normal comparisons; reach for Object.is when those two NaN/-0
edge cases actually matter.
JavaScript has a signed zero: +0 and -0 are distinct bit patterns. They're
equal under == and ===, but distinguishable with Object.is or by
dividing (which exposes the sign via Infinity).
+0 === -0 // true
Object.is(+0, -0) // false
1 / +0 // Infinity
1 / -0 // -Infinity
-0 arises from operations like -1 * 0 or Math.round(-0.1). It rarely matters,
but can surprise you in sign-sensitive math or when used as a Map key.
typeof returns 'function' for any callable — function declarations,
expressions, arrows, classes, and methods. This is a special case: functions are
technically objects, but typeof singles them out.
typeof function () {} // 'function'
typeof (() => {}) // 'function'
typeof class {} // 'function' (classes are functions under the hood)
typeof Math.max // 'function'
typeof [] // 'object' (arrays are NOT singled out)
It's the reliable way to check "is this callable?" before invoking
(typeof cb === 'function').
typeof returns one of eight strings:
typeof 'a' // 'string'
typeof 1 // 'number'
typeof true // 'boolean'
typeof undefined // 'undefined'
typeof 10n // 'bigint'
typeof Symbol() // 'symbol'
typeof function(){}// 'function'
typeof {} // 'object' (also arrays, null, dates, etc.)
The famous quirk is typeof null === 'object' — a bug kept for backward
compatibility. Anything not in the first seven categories (including arrays and
null) reports 'object', which is why you need Array.isArray and
=== null.
Primitives have no methods, yet 'hi'.toUpperCase() works because JavaScript
temporarily wraps the primitive in its object form (String, Number,
Boolean), calls the method, then discards the wrapper.
'hi'.length // 2 — boxed to a String object momentarily
(5).toFixed(2) // '5.00' — boxed to Number
// explicit wrapper objects are an anti-pattern:
const n = new Number(5)
typeof n // 'object', not 'number'
n === 5 // false
Never use new Number/new String — the resulting objects break === and are
always truthy (new Boolean(false) is truthy!). Let autoboxing happen
implicitly.
Number(x)converts the entire string to a number; any invalid character makes the whole thingNaN. Handles decimals.parseInt(x, radix)reads an integer prefix, stopping at the first non-numeric character, and ignores the rest. Always pass the radix.
Number('42px') // NaN
parseInt('42px', 10)// 42 (parses the leading 42)
Number('3.14') // 3.14
parseInt('3.14', 10)// 3 (integer only)
parseInt('0x1F', 16)// 31
Number('') // 0 (parseInt('') is NaN)
Use Number/parseFloat for exact full-string conversion; parseInt for
extracting a leading integer (e.g. '20px' -> 20).
The abstract equality (==) algorithm, simplified:
- Same type -> compare like
===. null == undefined->true(and they equal nothing else).- number vs string -> convert the string to a number.
- boolean vs anything -> convert the boolean to a number (
true->1,false->0). - object vs primitive -> convert the object to a primitive (
valueOf/toString), then retry.
'5' == 5 // string->number: 5 == 5 -> true
true == 1 // boolean->number: 1 == 1 -> true
[] == 0 // [] -> '' -> 0; 0 == 0 -> true
[] == ![] // ![] is false->0; [] -> 0; true
Knowing these steps explains every "wat" example — and is the best argument for
always using ===.
When an object is used where a primitive is expected, JS calls its
Symbol.toPrimitive method (if present) with a hint ('number', 'string',
or 'default'); otherwise it falls back to valueOf then toString.
const money = {
amount: 100,
[Symbol.toPrimitive](hint) {
return hint === 'string' ? `$${this.amount}` : this.amount
},
}
`${money}` // '$100' (hint 'string')
money + 1 // 101 (hint 'default' -> number)
money * 2 // 200 (hint 'number')
This lets you control how objects behave in concatenation, arithmetic, and template literals.
Without Symbol.toPrimitive, object-to-primitive conversion uses these two: for a
number hint it tries valueOf first; for a string hint it tries
toString first. Whichever returns a primitive wins.
const obj = {
valueOf() { return 42 },
toString() { return 'hello' },
}
obj + 1 // 43 (default/number hint -> valueOf)
`${obj}` // 'hello' (string hint -> toString)
String(obj) // 'hello'
Number(obj) // 42
Override toString (and sometimes valueOf) to make your objects coerce
sensibly in logs and expressions.
Template literals coerce every interpolated value to a string (using
String() / the object's toString). This is convenient but can produce
[object Object] or undefined/null text.
`count: ${5}` // 'count: 5'
`arr: ${[1, 2, 3]}` // 'arr: 1,2,3' (array toString joins with commas)
`obj: ${ {a: 1} }` // 'obj: [object Object]'
`val: ${null}` // 'val: null'
For objects, interpolate a specific field or JSON.stringify(obj) rather than
relying on the default [object Object].
JSON.stringify silently drops or transforms several types: undefined,
functions, and symbols are omitted in objects (or become null in arrays);
NaN/Infinity become null; BigInt throws; and Date becomes an ISO
string.
JSON.stringify({ a: undefined, b: () => {}, c: NaN })
// '{"c":null}' — a and b dropped, NaN -> null
JSON.stringify([undefined, function(){}, 1])
// '[null,null,1]'
JSON.stringify(10n) // TypeError: BigInt not serializable
It also calls a value's toJSON() if present, and ignores non-enumerable
properties. Know these when serializing for APIs or storage.
All JS numbers are 64-bit IEEE-754 doubles. Decimal fractions like 0.1 can't
be represented exactly in binary, so they're stored as the nearest approximation
and rounding errors accumulate.
0.1 + 0.2 // 0.30000000000000004
0.1 + 0.2 === 0.3 // false
// compare with a tolerance instead:
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true
For money, work in integer cents or use a decimal library. Never compare
floats with ===; use an epsilon tolerance.
BigInt represents integers of arbitrary precision, beyond the safe integer
limit of Number (2^53 - 1). Create one with an n suffix or BigInt().
Number.MAX_SAFE_INTEGER // 9007199254740991
9007199254740991 + 2 // 9007199254740992 (wrong! lost precision)
9007199254740991n + 2n // 9007199254740993n (exact)
Caveats: you can't mix BigInt and Number in arithmetic (1n + 1 throws),
typeof 1n === 'bigint', and it's integer-only (no decimals). Use it for large
IDs, timestamps in nanoseconds, and exact big-integer math.
a ?? b returns b only when a is null or undefined — unlike ||,
which falls back on any falsy value (0, '', false). This makes ??
correct for defaults where 0/'' are valid.
const count = 0
count || 10 // 10 treats valid 0 as "missing"
count ?? 10 // 0 only null/undefined trigger the default
const name = '' ?? 'anon' // '' (empty string is kept)
Use ?? when only "absent" should trigger the fallback. You can't mix ??
directly with &&/|| without parentheses (a syntax error by design).
?. short-circuits to undefined if the value before it is null/undefined,
instead of throwing — letting you safely access deep properties, call maybe-
missing methods, and index possibly-absent values.
user?.address?.city // undefined if user or address is null
user?.getName?.() // calls only if getName exists
arr?.[0] // safe index access
It pairs naturally with ??: user?.name ?? 'anon'. Note it stops at the first
nullish link and only guards against null/undefined — not other errors.
=== compares references, so two distinct arrays/objects with identical
contents are not equal. You must compare contents yourself.
[1, 2] === [1, 2] // false (different references)
// shallow array compare:
const eq = (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
// quick (but flawed) deep compare:
JSON.stringify(a) === JSON.stringify(b) // order/undefined-sensitive
JSON.stringify works for simple data but breaks on key order, undefined,
functions, and cycles. For robust deep equality, use a library (lodash
isEqual).
Several, with different strictness:
Number('42') // 42 (whole string; '' -> 0, '4a' -> NaN)
parseInt('42px', 10) // 42 (leading integer)
parseFloat('3.14m') // 3.14 (leading float)
+'42' // 42 (unary plus — concise coercion)
'42' * 1 // 42 (arithmetic coercion)
Number()/unary + are strict (invalid -> NaN); parseInt/parseFloat are
lenient (parse a prefix). Always validate with Number.isNaN afterward when the
input is untrusted.
!!x coerces any value to its boolean equivalent. The first ! converts to
the inverted boolean, the second ! flips it back — giving the truthiness as a
real true/false.
!!'hello' // true
!!0 // false
!!null // false
!![] // true (empty array is truthy)
Boolean('hello') // true — equivalent, more explicit
It's a common idiom to normalize a value to a strict boolean (e.g. before
returning from a predicate or storing a flag). Boolean(x) does the same thing
more readably.
A Symbol is a unique, immutable primitive, mainly used as non-colliding
object keys. Every Symbol() is distinct, even with the same description.
const id = Symbol('id')
const obj = { [id]: 123 }
obj[id] // 123
Symbol('x') === Symbol('x') // false — always unique
Uses: private-ish keys (not enumerable in for...in/JSON.stringify), and
well-known symbols that customize behavior (Symbol.iterator,
Symbol.toPrimitive, Symbol.asyncIterator). Symbol.for('k') accesses a
shared global registry.
An array's toString joins its elements with commas. So an empty array becomes
'', a single-element array becomes that element's string, and + with a string
concatenates — leading to several classic surprises.
[] + [] // '' (both -> '')
[] + {} // '[object Object]'
[1, 2] + [3] // '1,23' ('1,2' + '3')
Number([]) // 0 ('' -> 0)
Number([5]) // 5 ('5' -> 5)
Number([1, 2]) // NaN ('1,2' -> NaN)
These underpin the [] == ![] brain-teasers. The rule: array -> string (comma
join) -> then number if needed.
NaN results from invalid or undefined mathematical operations — converting
non-numeric strings, 0/0, Infinity - Infinity, or arithmetic on undefined.
And NaN is contagious: any arithmetic with it yields NaN.
Number('abc') // NaN
0 / 0 // NaN
Math.sqrt(-1) // NaN
undefined + 1 // NaN
parseInt('xyz', 10) // NaN
NaN + 5 // NaN (propagates)
Because it spreads, a single bad value can turn a whole calculation into NaN —
guard inputs and check with Number.isNaN.
undefined is a value: a declared variable that hasn't been assigned, or a
missing property. "Not defined" means the identifier doesn't exist at all —
accessing it throws a ReferenceError.
let a
console.log(a) // undefined (declared, no value)
console.log(b) // ReferenceError: b is not defined
const obj = {}
console.log(obj.x) // undefined (missing property, no error)
So undefined is recoverable (it's just a value); a "not defined" reference is an
error. typeof is the safe way to check the latter without throwing.
typeof is special-cased to not throw a ReferenceError for an undeclared
identifier — it returns 'undefined'. This makes it the safe way to feature-
detect a global that may not exist.
typeof someUndeclaredVar // 'undefined' (no error)
someUndeclaredVar // ReferenceError
// safe environment checks:
if (typeof window !== 'undefined') { /* browser */ }
Caveat: this leniency does not apply to let/const in the temporal dead
zone — typeof x before a let x declaration still throws.
A primitive value itself can never be changed — operations that look like mutation actually create a new value. You can reassign the variable, but the original primitive is untouched.
let s = 'hello'
s.toUpperCase() // 'HELLO' (a new string)
console.log(s) // 'hello' (original unchanged)
s[0] = 'J' // silently fails (or throws in strict mode)
s = 'world' // reassigning the variable is fine
Contrast objects/arrays, which are mutable (you can change their contents in place). Immutability is why primitives compare by value and are safe to share.
Relational operators compare strings lexicographically by UTF-16 code unit, character by character. This is case-sensitive (uppercase letters have lower code points than lowercase) and not locale-aware.
'apple' < 'banana' // true
'Z' < 'a' // true (90 < 97)
'10' < '9' // true (string compare: '1' < '9')
'10' < 9 // false (mixed -> numeric: 10 < 9)
For human-friendly, locale-aware ordering (accents, case-insensitive), use
a.localeCompare(b). Note mixed string/number comparisons coerce to numbers.
Shallow equality compares the top level: same reference, or matching primitive values / first-level properties. Deep equality recursively compares nested structures for equivalent contents.
const a = { x: { y: 1 } }
const b = { x: { y: 1 } }
a.x === b.x // false (different nested references)
// shallow: equal keys but nested refs differ -> not equal
// deep: recursively equal -> equal
React uses shallow comparison for re-render decisions (props/state), which is why
mutating nested objects can be missed. Deep equality (lodash isEqual) is
costlier but compares full structure.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.