Common JavaScript Gotchas

Difficulty: Advanced

JavaScript has many behaviors that surprise even experienced developers. These 'gotchas' stem from design decisions made in the language's early days, the IEEE 754 floating-point standard, type coercion rules, and the dynamic nature of the language. Knowing these gotchas is not just trivia - they appear frequently in interviews and understanding them demonstrates deep knowledge of the language internals.

typeof null returning 'object' is perhaps the most famous JavaScript bug. In the original implementation, values were stored with a type tag, and the tag for objects was 0. null was represented as the NULL pointer (0x00), so its type tag was also 0, causing typeof to classify it as an object. This bug was identified early but a proposed fix was rejected because it would break too much existing code. To check for null, use value === null instead of typeof.

NaN (Not-a-Number) has two peculiar properties: it is the only value not equal to itself (NaN !== NaN is true), and its type is 'number' (typeof NaN === 'number'). NaN results from invalid numeric operations like 0/0, parseInt('hello'), or undefined + 1. To check for NaN, use Number.isNaN(value) rather than the global isNaN() which coerces its argument first. For example, isNaN('hello') returns true because it converts the string to NaN first, while Number.isNaN('hello') correctly returns false.

Floating-point arithmetic follows the IEEE 754 standard, which cannot represent all decimal fractions exactly. The classic example is 0.1 + 0.2 === 0.30000000000000004, not 0.3. This affects all languages using IEEE 754 doubles, not just JavaScript. The practical workaround is to compare with a tolerance: Math.abs(a - b) < Number.EPSILON for simple cases, or to work with integers (cents instead of dollars, for example) for financial calculations.

Array and object comparison is another common trap. Arrays and objects are compared by reference, not by value. So [] === [] is false because they are two different objects in memory. Even more confusingly, [] == false evaluates to true because of type coercion: [] converts to '' which converts to 0, and false converts to 0. Yet Boolean([]) is true because all objects (including empty arrays) are truthy. This leads to the paradox: an empty array is truthy, but it equals false.

Automatic Semicolon Insertion (ASI) can silently change the meaning of your code. JavaScript automatically inserts semicolons at certain line breaks. The most dangerous case is a return statement followed by a newline: return on its own is a valid statement, so the engine inserts a semicolon after it, causing the function to return undefined regardless of what follows on the next line. Other gotchas include the arguments object (array-like but not an array), the delete operator (removes properties but does not free memory or affect local variables), and for...in iterating over prototype properties unless hasOwnProperty is checked.

Code examples

typeof null, NaN, and undefined

// typeof null is 'object' (historical bug)
console.log(typeof null);        // 'object'
console.log(typeof undefined);    // 'undefined'
console.log(null === undefined);  // false
console.log(null == undefined);   // true (special coercion rule)

// NaN is not equal to itself
console.log(NaN === NaN);         // false
console.log(NaN == NaN);          // false
console.log(typeof NaN);          // 'number'

// Checking for NaN correctly
console.log(Number.isNaN(NaN));           // true
console.log(Number.isNaN('hello'));       // false (no coercion)
console.log(isNaN('hello'));              // true (coerces to NaN first!)

// Safe null check
function isNull(value) {
  return value === null;
}
console.log(isNull(null));      // true
console.log(isNull(undefined)); // false
console.log(isNull(0));         // false
console.log(isNull(''));        // false

typeof null is 'object' due to a legacy bug. NaN !== NaN because IEEE 754 defines NaN as not equal to any value, including itself. Number.isNaN is the correct way to check for NaN - the global isNaN coerces its argument first, leading to false positives.

Floating point and array comparison

// Floating point precision
console.log(0.1 + 0.2);               // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3);       // false!

// Fix: compare with tolerance
function almostEqual(a, b) {
  return Math.abs(a - b) < Number.EPSILON;
}
console.log(almostEqual(0.1 + 0.2, 0.3)); // true

// Fix: work with integers
const priceInCents = 10 + 20; // 30 cents
console.log(priceInCents === 30); // true

// Array comparison - by reference, not value
console.log([] === []);         // false (different objects)
console.log([1, 2] === [1, 2]); // false
const a = [1, 2];
const b = a;
console.log(a === b);           // true (same reference)

// The empty array paradox
console.log([] == false);        // true (coercion: [] -> '' -> 0 == 0)
console.log(!![]);               // true ([] is truthy!)
console.log(Boolean([]));        // true

// Compare arrays by value
function arraysEqual(a, b) {
  return a.length === b.length && a.every((v, i) => v === b[i]);
}
console.log(arraysEqual([1, 2], [1, 2])); // true
console.log(arraysEqual([1, 2], [1, 3])); // false

IEEE 754 cannot represent 0.1 exactly in binary, causing precision errors. Arrays compare by reference - use JSON.stringify or element-by-element comparison for value equality. The empty array is truthy (all objects are) but coerces to 0 via '' when compared with ==.

ASI, parseInt, and arguments

// Automatic Semicolon Insertion - silent return undefined
function getObject() {
  return
  {
    name: 'Alice'
  };
}
console.log(getObject()); // undefined! ASI inserts ; after return

// Fix: open brace on same line as return
function getObjectFixed() {
  return {
    name: 'Alice'
  };
}
console.log(getObjectFixed()); // { name: 'Alice' }

// parseInt gotchas
console.log(parseInt('08'));          // 8 (modern engines)
console.log(parseInt('0x10'));        // 16 (hex)
console.log(parseInt('123abc'));      // 123 (stops at non-digit)
console.log(parseInt('abc'));         // NaN
console.log(parseInt(0.0000005));     // 5! (toString -> '5e-7')

// arguments is not a real array
function showArgs() {
  console.log(typeof arguments);         // 'object'
  console.log(Array.isArray(arguments)); // false
  // arguments.map(x => x); // TypeError: not a function

  // Convert to real array
  const args = Array.from(arguments);
  console.log(args.map(x => x * 2));
}
showArgs(1, 2, 3);

// arguments does not exist in arrow functions
const arrowFn = () => {
  try {
    console.log(arguments);
  } catch (e) {
    console.log('No arguments in arrow:', e.constructor.name);
  }
};
arrowFn(1, 2);

ASI inserts a semicolon after 'return' on its own line, making the function return undefined. parseInt(0.0000005) returns 5 because the number is first converted to string '5e-7' and parseInt stops at 'e'. arguments is array-like but lacks array methods.

delete operator, for...in, and equality edge cases

// delete removes properties but doesn't free memory
const obj = { a: 1, b: 2, c: 3 };
console.log(delete obj.b);
console.log(obj);

// delete does NOT work on local variables
var x = 10;
console.log(delete x);  // false in strict mode
console.log(x);         // still 10

// for...in iterates over prototype properties
function Animal(name) {
  this.name = name;
}
Animal.prototype.speak = function() { return 'hello'; };

const cat = new Animal('Whiskers');
for (const key in cat) {
  console.log(`${key}: own=${cat.hasOwnProperty(key)}`);
}
// Fix: always use hasOwnProperty or Object.keys()
console.log(Object.keys(cat));

// == equality edge cases
console.log('' == false);       // true (both coerce to 0)
console.log('0' == false);      // true ('0' -> 0, false -> 0)
console.log('' == '0');          // false (both strings, different values)
console.log(false == undefined); // false
console.log(false == null);      // false
console.log(null == undefined);  // true (special rule)

// Use === to avoid all coercion surprises
console.log('' === false);      // false
console.log('0' === false);     // false
console.log(null === undefined); // false

delete removes object properties but cannot delete variables. for...in iterates over all enumerable properties including inherited ones - use Object.keys() for own properties only. The == operator's coercion rules create a non-transitive equality: '' == false and '0' == false are true, but '' == '0' is false.

More gotchas: labels, commas, and void

// String to number conversions
console.log(+'');         // 0
console.log(+' ');        // 0
console.log(+true);       // 1
console.log(+false);      // 0
console.log(+null);       // 0
console.log(+undefined);  // NaN
console.log(+[]);         // 0 ([] -> '' -> 0)
console.log(+[1]);        // 1 ([1] -> '1' -> 1)
console.log(+[1, 2]);     // NaN ([1,2] -> '1,2' -> NaN)

// Comma operator returns last value
const val = (1, 2, 3);
console.log(val);

// void always returns undefined
console.log(void 0);
console.log(void 'hello');

// Labelled statements (rarely used but valid)
outer: for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    if (i === 1 && j === 1) {
      console.log(`Breaking at i=${i}, j=${j}`);
      break outer;
    }
  }
}

// typeof undeclared variable doesn't throw
console.log(typeof neverDeclared); // 'undefined' (no error!)

// Modifying array length
const arr = [1, 2, 3, 4, 5];
arr.length = 3;
console.log(arr);
arr.length = 5;
console.log(arr);

Unary + converts values to numbers with surprising results for arrays and booleans. The comma operator evaluates all expressions but returns only the last. void always returns undefined. Setting array.length truncates the array; increasing it creates sparse slots.

Key points

Concepts covered

typeof null, NaN, Floating Point, Type Coercion, ASI, arguments, Array Comparison, delete Operator