Type Coercion

Why can a value be truthy and still compare loosely equal to false? Start with the operation, not a rule that says “JavaScript converts this value to that value.” Boolean tests, numeric conversion and equality ask different questions.

You should already be able to run basic JavaScript, call functions and follow an if statement or a loop. The examples are independent JavaScript blocks unless a local file is named. They use Node 24. TypeScript isn't a prerequisite.

First pass: trace a string through three conversions, distinguish a missing value from a falsy one and explain the empty-array comparison. Then change an input under Practice and stop here. The longer conversion reference and object-hook details are available when you need them. Structural deep equality is a separate HARD extension.

For values and shared object identity, see Data Types. This lesson explains what operations do with those values rather than changing their storage rules.

Convert deliberately

A non-empty string containing a zero isn't the same thing as a numeric zero:

const input = '0'
console.log(Boolean(input)) // true
console.log(Number(input)) // 0
console.log(String(input)) // '0'
console.log(input + 1) // '01'
console.log(Number(input) + 1) // 1
Operation on this inputQuestion it asksResult
Boolean(input)Is the value truthy?true
Number(input)What Number does the string represent?0
input + 1Does addition choose string concatenation?'01'
Number(input) + 1What is the numeric sum?1

Calling String, Number or Boolean names the conversion explicitly. An operator can request conversion implicitly. Neither label means every input is valid or that an object's conversion hooks are harmless.

console.log('5' + 3) // '53'
console.log('5' - 3) // 2
console.log('5' * '2') // 10
console.log('10' / '2') // 5
console.log('5' - true) // 4
console.log('5' - null) // 5

Conversion reference

Keep these results for lookup. They use ordinary arrays and objects with unmodified conversion methods.

console.log(String(123), String(true)) // '123' 'true'
console.log(String(null), String(undefined)) // 'null' 'undefined'
console.log(String([1, 2, 3])) // '1,2,3'
console.log(String({ a: 1 })) // '[object Object]'
console.log(String(Symbol('id'))) // 'Symbol(id)'

console.log(Number('123')) // 123
console.log(Number('123abc')) // NaN
console.log(Number('')) // 0
console.log(Number('   ')) // 0
console.log(Number(true), Number(false)) // 1 0
console.log(Number(null), Number(undefined)) // 0 NaN
console.log(Number([]), Number([5]), Number([1, 2])) // 0 5 NaN

Number('') returning zero doesn't mean an empty form field is valid. Decide whether blank text is allowed before converting it. That distinction will matter in the input-validation example below.

TypeScript can reject legal JavaScript

This is an intentional diagnostic example, not a passing TypeScript alternative. Under TypeScript 5.9.3 with strict checking, these two lines report TS2362 and TS2367:

// diagnostics.ts: intentional compiler errors
const text = '5'
const difference = text - 3
const same = 5 === '5'
TS2362: the arithmetic operand's type is not accepted.
TS2367: the compared types have no overlap.

The corresponding JavaScript evaluates to 2 and false. TypeScript is warning about the code's types, not defining a different runtime conversion algorithm. Don't cast away the warnings to make a duplicate pane appear valid. An explicit conversion such as Number(text) - 3 states the intended operation.

Boolean contexts and default values

Boolean(value) and !!value give the same boolean. An if, while or ternary condition uses the same truthiness decision. The eight ordinary falsy values are false, 0, -0, 0n, '', null, undefined and NaN.

const falsy = [false, 0, -0, 0n, '', null, undefined, NaN]
console.log(falsy.map(Boolean)) // eight false values

const truthy = ['0', 'false', ' ', [], {}, () => {}, -1, new Boolean(false)]
console.log(truthy.map(Boolean)) // eight true values

An empty array is an ordinary object, not an empty string. A Boolean wrapper is also an object even when it wraps false. Prefer the primitive boolean unless you have a specific reason to use a wrapper.

Browsers preserve the legacy exception document.all: it is falsy and loosely equal to null and undefined, but it is neither of them. That browser-specific exception doesn't make ordinary objects falsy. Don't use it as an application pattern. The falsy-value reference includes this browser boundary.

Logical operators test truthiness without necessarily returning a boolean:

const name = ''
const retries = 0
console.log(name || 'guest') // 'guest'
console.log(retries || 3) // 3
console.log(retries ?? 3) // 0
console.log(null ?? 'fallback') // 'fallback'
console.log('ready' && 42) // 42
console.log(0 && 'unused') // 0

a || b returns a when it is truthy, otherwise it evaluates and returns b. a && b returns a when it is falsy, otherwise it evaluates and returns b. a ?? b uses the right operand only for null or undefined. All three short-circuit: an unused right-hand expression isn't evaluated.

Use ?? for a missing-value default when zero, false or empty text must remain meaningful. Use a truthiness test when the contract really treats every falsy value as absent.

Choose the equality rule

=== doesn't convert operands. Different types compare false. Objects compare by identity, not by their fields. Number equality has two important cases: NaN doesn't strictly equal itself and the two signed zeroes do compare equal.

console.log(5 === 5) // true
console.log(5 === '5') // false
console.log(null === undefined) // false
console.log(NaN === NaN) // false
console.log(0 === -0) // true
console.log([] === []) // false
console.log(({}) === ({})) // false
const values = [1, 2, 3]
const alias = values
console.log(values === alias) // true

Parentheses and console.log put object literals and adjacent expressions in a clear expression context. You can paste this entire block rather than relying on how a console parses isolated snippets.

Object.is also avoids conversion and compares objects by identity. It matches NaN with itself and distinguishes signed zeroes. That isn't universally “stricter” than ===, just a different rule.

console.log(Object.is(NaN, NaN)) // true
console.log(Object.is(0, -0)) // false
console.log([NaN].includes(NaN)) // true
console.log([NaN].indexOf(NaN)) // -1
console.log(new Set([NaN, NaN, 0, -0]).size) // 2

includes, Set values and Map keys use SameValueZero: it matches NaN values and treats the signed zeroes as equal. These APIs still compare objects by identity. None performs a deep structural comparison.

Loose equality is an operation-specific comparison

== can convert operands, but it doesn't simply call Number on everything:

console.log(5 == '5') // true
console.log(0 == '') // true
console.log(false == '0') // true
console.log(null == undefined) // true
console.log(null == 0) // false
console.log([1] == 1) // true
console.log([1, 2] == '1,2') // true
console.log(2n == 2) // true
console.log(2n == '2') // true
console.log(2n == '2.0') // false

For the examples here, these branches explain the result:

  • Same types use strict-equality behavior.
  • Among ordinary values, null and undefined loosely match each other but not other values. The browser's document.all exception also applies.
  • A Number and a string compare after numeric conversion of the string.
  • A boolean is converted to a Number before continuing the comparison.
  • An object paired with a string, Number, BigInt or symbol supplies a primitive first. Comparing two objects still tests identity.
  • A BigInt/string pair uses integer-string conversion. A BigInt/Number pair compares mathematical values without pretending all BigInts fit exactly in Number. NaN and infinities don't match a BigInt.

These are the relevant branches, not a replacement implementation of the full specification. Symbols don't loosely match their description strings.

One useful deliberate shorthand is value == null for ordinary null-or-undefined checks. Use the explicit pair if your contract must exclude document.all too:

function isMissing(value) {
  return value === null || value === undefined
}
console.log(isMissing(null), isMissing(undefined)) // true true
console.log(isMissing(0), isMissing('')) // false false

Whether a lint rule permits == depends on configuration. Prefer explicit intent, not a claim that the language or every linter bans it.

Addition chooses after primitive conversion

Binary + first obtains primitive values from its operands using the default hint. If either resulting primitive is a string, it performs string concatenation. Otherwise it requests numeric values and adds them. The two numeric values must both be Numbers or both be BigInts.

console.log('5' + 3) // '53'
console.log(5 + '3') // '53'
console.log(1 + 2 + '3') // '33'
console.log('1' + 2 + 3) // '123'
console.log(2n + 1n) // 3n
console.log(2n + '1') // '21'
console.log('5' - -'3') // 8

Evaluation is left to right in these addition chains. The first sum in 1 + 2 + '3' is numeric. The first sum in '1' + 2 + 3 is a string, which remains a string for the next addition.

For -, *, / and %, numeric conversion uses ToNumeric, which can produce a Number or a BigInt. Unary + instead uses ToNumber and rejects a BigInt. Number(bigint) explicitly allows conversion, with possible precision loss for large values.

console.log('5' - 3) // 2
console.log(5n - 3n) // 2n
console.log(Number(2n)) // 2
console.log(+'5', +'', +true, +null) // 5 0 1 0
console.log(+undefined, +[], +[5], +{}) // NaN 0 5 NaN
for (const operation of [() => +2n, () => 2n + 1, () => Number(Symbol('x'))]) {
  try {
    operation()
  } catch (error) {
    console.log(error.name) // TypeError for each operation
  }
}

Calling String directly on a symbol is supported, but implicit string conversion during concatenation rejects symbols:

const token = Symbol('id')
console.log(String(token)) // 'Symbol(id)'
try {
  console.log('id: ' + token)
} catch (error) {
  console.log(error.name) // TypeError
}

How an object supplies a primitive

This is reference depth for explaining object operands. You don't need custom hooks to use explicit conversion on ordinary input.

An object's callable Symbol.toPrimitive hook takes priority. It receives 'number', 'string' or 'default' and must return a primitive. Returning another object throws:

const hints = []
const value = {
  [Symbol.toPrimitive](hint) {
    hints.push(hint)
    if (hint === 'number') return 10
    if (hint === 'string') return 'ten'
    return true
  },
}
console.log(Number(value)) // 10
console.log(String(value)) // 'ten'
console.log(value + '') // 'true'
console.log(hints) // ['number', 'string', 'default']

const bad = { [Symbol.toPrimitive]() { return {} } }
try {
  console.log(Number(bad))
} catch (error) {
  console.log(error.name) // TypeError
}

A non-nullish hook that isn't callable also throws. If the hook is absent or nullish, ordinary conversion tries methods in order:

  • A string hint tries toString, then valueOf.
  • A number hint tries valueOf, then toString.
  • Without a special hook, the default hint takes the ordinary number-preferred route.

Only callable methods are tried. The first primitive result wins. If neither method produces a primitive, conversion throws. Property lookup and method calls can themselves have effects or throw, which is another reason not to use conversion as an arbitrary-object validator.

const value = {
  valueOf() { return 42 },
  toString() { return 'hello' },
}
console.log(Number(value)) // 42
console.log(String(value)) // 'hello'
console.log(value + 0) // 42
console.log(value + '') // '42'

Date has its own Symbol.toPrimitive behavior: the default hint is string-preferred. Numeric conversion of a Date still yields its time value:

const date = new Date(0)
console.log(Number(date)) // 0
console.log(date - 0) // 0
console.log(typeof (date + 1)) // 'string'
console.log(date + 1 === String(date) + '1') // true

The exact date text depends on the host's timezone. The equality in this example doesn't depend on choosing one timezone.

For ordinary arrays, valueOf returns the array itself, so conversion continues to toString. An empty array supplies ''. This is the step that connects an empty array to zero during the loose-equality example, not during a truthiness test.

Validate a value before using it

First choose the input contract. “Already a finite Number” is different from “text I am willing to parse as a Number.”

function requireFiniteNumber(value) {
  if (typeof value !== 'number' || !Number.isFinite(value)) {
    throw new TypeError('Expected a finite Number')
  }
  return value
}
console.log(requireFiniteNumber(0)) // 0
try {
  requireFiniteNumber('5')
} catch (error) {
  console.log(error.message) // Expected a finite Number
}

The TypeScript version uses the runtime guard to narrow unknown. It doesn't assert that a string was already a number. Node can execute this erasable TypeScript syntax, but native execution isn't a type-check.

For text input, reject blanks if they are invalid before using Number. Prefix parsers answer a different question:

console.log(parseInt('42', 10)) // 42
console.log(parseInt('42px', 10)) // 42
console.log(parseInt('')) // NaN
console.log(parseInt('0x10')) // 16
console.log(parseInt('10', 2)) // 2
console.log(parseFloat('3.14abc')) // 3.14
console.log(Number('42px')) // NaN
console.log(Number('')) // 0

parseInt and parseFloat can accept a numeric prefix and leave trailing text unconsumed. Number processes the whole numeric string, including its whitespace rules. None alone establishes your application's accepted input format.

For type checks, typeof distinguishes primitive categories but reports 'object' for null. Use Array.isArray for arrays. instanceof Date asks about a prototype relationship, not whether an arbitrary value is a finite numeric input.

Number.isNaN(value) asks whether the value is already the Number NaN. Global isNaN(value) first requests Number conversion and can throw. Choose the question you actually mean:

console.log(Number.isNaN('hello')) // false
console.log(isNaN('hello')) // true
console.log(isNaN(''), isNaN(null), isNaN([])) // false false false
console.log(Number.isFinite('5'), isFinite('5')) // false true
console.log(Number.isFinite(NaN), Number.isFinite(Infinity)) // false false

Explain a trace without memorizing tricks

When predicting an expression, name the operation and follow only the conversions it requests:

  1. Identify the operator and the operand types.
  2. If an object must supply a primitive, identify the hint and the methods involved.
  3. Apply that operator's comparison, boolean or numeric rule.
  4. State the result's value and type. Then change an input and see whether the same path still applies.

For example, Boolean conversion doesn't ask an array for a primitive. Loose comparison with a number does. Explicitly separating those paths is more useful than saying an empty array “acts like zero.”

Use === when no conversion is intended. Choose Object.is or a collection's equality rule when its NaN or signed-zero behavior matches the task. None is a universal ranking from “weak” to “strong.”

For implementation details beyond these observable rules, see Engine and Runtime. Explaining a result doesn't require guessing how an engine allocates or optimizes it.

Predict, then check

Predict these results before running the complete block:

console.log([] == false) // true
if ([]) console.log('truthy') // truthy
console.log([] == ![]) // true

[] == false takes the comparison path:

[] == false
[] == 0             convert the boolean to a Number
'' == 0             the ordinary array supplies a primitive
0 == 0              convert the string to a Number
true                compare the two Numbers

The primitive step tries valueOf, which returns the array, then toString, which returns ''. if ([]) takes a different path: an ordinary object is truthy, so the branch runs without those primitive-conversion calls.

For [] == ![], evaluate ![] first. The array is truthy, so negation gives false. The remaining expression is the first loose-equality case again.

Try nearby values rather than treating that trace as a rule for all arrays:

console.log([1] == false) // false
console.log(['0'] == false) // true
console.log(Boolean([1]), Boolean(['0'])) // true true
console.log('0' == 0, 0 == '', '0' == '') // true true false

The nonempty strings '1' and '0' give different numbers in the comparison path. Both arrays remain truthy. The final line also shows why loose equality isn't transitive across those three types/values.

More lookup cases

console.log('5' - 3, '5' + 3, '5' - -'3') // 2 '53' 8
console.log([] + []) // ''
console.log([] + {}) // '[object Object]'
console.log(({}) + []) // '[object Object]'
console.log([1, 2] + [3, 4]) // '1,23,4'
console.log(NaN === NaN, Number.isNaN(NaN), Number.isNaN('NaN')) // false true false
console.log(NaN + 1, NaN * 0, NaN ** 0) // NaN NaN 1

At the start of a classic-script statement, {} can be an empty block rather than an object literal. Then the remaining +[] is unary plus and produces zero. Parenthesizing the object, as above, makes the expression context explicit. Don't treat a console's choice of parsing mode as a different coercion rule.

NaN commonly propagates through Number arithmetic, but not universally: the Number exponentiation rule returns 1 for NaN ** 0. The chosen equality API also matters, as the earlier Object.is and includes examples show.

Practice and stop here

Change the input, not just the expected output

For each of '', '0' and '5', predict Boolean(input), Number(input), input + 1 and Number(input) + 1. Explain why a truthy string doesn't imply a nonzero Number.

for (const input of ['', '0', '5']) {
  console.log(Boolean(input), Number(input), input + 1, Number(input) + 1)
}
// false 0 '1' 1
// true  0 '01' 1
// true  5 '51' 6

Now write readAmount(text) for a string input. Reject blank text and any value that doesn't convert to a finite Number. Return the Number otherwise. This exercise accepts Number's numeric-string formats, not only decimal digits.

function readAmount(text) {
  if (typeof text !== 'string' || text.trim() === '') {
    throw new TypeError('Expected nonblank text')
  }
  const value = Number(text)
  if (!Number.isFinite(value)) throw new TypeError('Expected a finite amount')
  return value
}
console.log(readAmount('0')) // 0
console.log(readAmount(' 5 ')) // 5
for (const text of ['', '   ', '5px', 'Infinity']) {
  try {
    readAmount(text)
  } catch (error) {
    console.log(error.name) // TypeError for each input
  }
}

The blank check is part of the input policy. Removing it would make empty text become zero. If your application permits only a decimal format, add that format rule deliberately rather than assuming Number enforces it.

Stop after the first pass when you can explain the changed-input trace and choose the validation rule for this small function. Truthiness, loose equality and valid numeric input are separate questions.

Keep these for a later check

The original comparison, concatenation and filtering exercises still make useful small probes:

console.log(0 == '', 0 == '0', '' == '0') // true true false
console.log(null == undefined, null == 0, NaN == NaN) // true false false
console.log(1 + '2' + 3, 1 + 2 + '3', '5' - 2) // '123' '33' 3
const values = [0, '', '0', [], {}, null, undefined, NaN, '  ']
console.log(values.filter(Boolean).length) // 4

The four survivors are '0', the array, the object and the whitespace string. Filtering with Boolean tests truthiness, not whether the text represents a useful amount.

The HARD structural-equality task is a separate extension. Practice recursion, graph traversal and one-to-one matching, then read its current question before attempting it. The first-pass stopping point doesn't depend on completing that extension.

Practice Problems

Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.

TheoryEasy

The `+` Operator's Dual Nature

TheoryEasy

Truthy and Falsy Values

TheoryMedium

`[] == false` and `if ([])` — Output Prediction

TheoryMedium

`NaN` Semantics

TheoryMedium

`==` vs `===`

CodingHard

Implement `deepEqual` (Structural Equality)

Lesson completed?

Found a bug, typo, or have feedback?

Let me know