Lesson 6 of 69

JavaScript Under the Hood

JavaScript Interview Traps: A Survival Guide

I have reviewed hundreds of technical interviews as a senior engineer, and I keep seeing the same traps trip up candidates who otherwise know their material. These are not obscure edge cases. They are things that appear in codebases and in interview questions every single week. This lesson is a structured reference: each trap, followed by the explanation, followed by the fix.

Trap 1: Event Loop Ordering

The event loop is almost always on the interview rubric for senior positions. The question is usually: "what does this code print, and in what order?"

graph diagram: Call Stack\n(synchronous code)

The rule:

  1. Run all synchronous code in the call stack.
  2. Drain the entire microtask queue (Promises, queueMicrotask).
  3. Take one macrotask (setTimeout, setInterval, I/O callbacks).
  4. Drain the entire microtask queue again.
  5. Repeat.
Typescript
console.log("1"); // Synchronous

setTimeout(() => console.log("2"), 0); // Macrotask

Promise.resolve().then(() => console.log("3")); // Microtask

queueMicrotask(() => console.log("4")); // Microtask

console.log("5"); // Synchronous

// Output: 1, 5, 3, 4, 2

Explanation:

  • "1" and "5" run synchronously first.
  • "3" and "4" run before "2" because Promise callbacks and queueMicrotask go into the microtask queue, which is drained before any macrotask runs.
  • setTimeout(fn, 0) does not mean "run immediately." It means "enqueue as a macrotask after at least 0ms." The microtask queue always runs first.

Advanced version: microtasks spawning microtasks keep the microtask queue going before any macrotask can run.

Typescript
Promise.resolve()
  .then(() => {
    console.log("A");
    // This queues another microtask before setTimeout can run
    return Promise.resolve();
  })
  .then(() => console.log("B"));

setTimeout(() => console.log("C"), 0);

// Output: A, B, C

Trap 2: Closure in Loop with var

Covered in the closures lesson, but it appears so frequently in interviews that it belongs here too.

Problem:

Typescript
const fns: Array<() => void> = [];
for (var i = 0; i < 3; i++) {
  fns.push(() => console.log(i));
}
fns.forEach((f) => f()); // 3, 3, 3

Explanation: var is function-scoped. There is one i shared by all closures. After the loop, i is 3.

Fix:

Typescript
// Fix 1: use let (block-scoped, new binding per iteration)
const fnsFixed: Array<() => void> = [];
for (let i = 0; i < 3; i++) {
  fnsFixed.push(() => console.log(i));
}
fnsFixed.forEach((f) => f()); // 0, 1, 2

// Fix 2: capture with an IIFE (legacy code)
const fnsIIFE: Array<() => void> = [];
for (var j = 0; j < 3; j++) {
  ((captured) => fnsIIFE.push(() => console.log(captured)))(j);
}
fnsIIFE.forEach((f) => f()); // 0, 1, 2

Trap 3: Mutating Object Arguments

Primitives (numbers, strings, booleans) are passed by value. Objects are passed by reference. This causes silent bugs when a function modifies an object that the caller did not expect to change.

Problem:

Typescript
function addRole(user: { name: string; roles: string[] }, role: string): void {
  user.roles.push(role); // Mutates the caller's object
}

const alice = { name: "Alice", roles: ["viewer"] };
addRole(alice, "editor");
console.log(alice.roles); // ['viewer', 'editor']: original is mutated!

Explanation: user in the function is the same object reference as alice in the caller. Pushing to user.roles pushes to alice.roles.

Fix: Return a new object instead of mutating, or explicitly clone if mutation is intended.

Typescript
// Immutable approach
function addRoleImmutable(
  user: { name: string; roles: string[] },
  role: string
): { name: string; roles: string[] } {
  return { ...user, roles: [...user.roles, role] }; // New object, new array
}

const alice = { name: "Alice", roles: ["viewer"] };
const updated = addRoleImmutable(alice, "editor");

console.log(alice.roles); // ['viewer']: untouched
console.log(updated.roles); // ['viewer', 'editor']

The spread only does a shallow clone. If user had nested objects, those would still be shared references. For deep cloning, use structuredClone() (available in Node.js 17+ and modern browsers).

Trap 4: NaN Comparisons

NaN is the only value in JavaScript that is not equal to itself. This means NaN === NaN is false. It also means typeof NaN === 'number' is true, which surprises people every time.

Problem:

Typescript
const result = parseInt("not a number");
console.log(result); // NaN

console.log(result === NaN); // false: NaN !== NaN always
console.log(result == NaN); // false: same issue with ==
console.log(typeof result === "number"); // true: NaN is of type 'number'

// The old way (do not use)
console.log(isNaN("hello")); // true: but coerces the argument first!
console.log(isNaN(undefined)); // true: undefined coerces to NaN

Fix: Use Number.isNaN, which does not coerce its argument.

Typescript
console.log(Number.isNaN(NaN)); // true
console.log(Number.isNaN(undefined)); // false: no coercion
console.log(Number.isNaN("hello")); // false: not a number type
console.log(Number.isNaN(parseInt("not a number"))); // true

// Safe number validation pattern
function parseSafeInt(input: string): number | null {
  const parsed = parseInt(input, 10);
  return Number.isNaN(parsed) ? null : parsed;
}

console.log(parseSafeInt("42")); // 42
console.log(parseSafeInt("abc")); // null

Always use Number.isNaN, not global isNaN. And always pass a radix (10) to parseInt to avoid octal parsing on strings starting with "0".

Trap 5: Floating Point Precision

Problem:

Typescript
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

Explanation: JavaScript uses IEEE 754 double-precision floating point. 0.1 and 0.2 cannot be represented exactly in binary. The small errors accumulate.

Fix: For comparison, use an epsilon (acceptable margin of error). For financial calculations, use integers (store cents, not dollars) or a library like decimal.js.

Typescript
const EPSILON = Number.EPSILON; // 2.220446049250313e-16

function nearlyEqual(a: number, b: number, epsilon = EPSILON): boolean {
  return Math.abs(a - b) < epsilon;
}

console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true

// For display, round to significant digits
console.log((0.1 + 0.2).toFixed(2)); // "0.30"
console.log(parseFloat((0.1 + 0.2).toFixed(10))); // 0.3

// For money: work in integers
const priceInCents = 10 + 20; // 30 cents, exact
console.log(priceInCents / 100); // 0.3, display only

Trap 6: == vs === and Type Coercion

=== (strict equality) checks both value and type. == (loose equality) applies type coercion rules that are often surprising.

The coercion rules for == are complex:

Typescript
console.log(0 == false); // true: false coerces to 0
console.log("" == false); // true: both coerce to 0
console.log(null == undefined); // true: special case in the spec
console.log(null == 0); // false: null only equals undefined
console.log([] == false); // true: [] → "" → 0, false → 0
console.log([] == ![]); // true: one of the most confusing results in JS

// Arithmetic coercion
console.log("5" + 3); // "53": + triggers string concatenation
console.log("5" - 3); // 2: - triggers numeric conversion
console.log("5" * "3"); // 15: both coerce to numbers
console.log(true + true); // 2: booleans coerce to 0/1

Fix: Always use ===. The only intentional use of == in modern code is x == null, which checks for both null and undefined in one expression.

Typescript
function processValue(x: string | number | null | undefined): void {
  if (x == null) {
    // catches both null and undefined
    console.log("no value");
    return;
  }
  // TypeScript now knows x is string | number
  console.log(x);
}

In TypeScript, the type system prevents most accidental coercions. But understanding == is still tested in interviews because it reveals how well you know the runtime.

Trap 7: Temporal Dead Zone in Practice

The TDZ was covered in the closures lesson, but its practical manifestation in real code deserves its own entry. The most common case is circular imports or out-of-order initialisation.

Problem:

Typescript
// This works with var but not with let/const
function demonstrateTDZ(): void {
  // Using before declaration
  try {
    const upper = name.toUpperCase(); // ReferenceError
    console.log(upper);
  } catch (e) {
    console.log("TDZ error:", (e as Error).message);
  }

  const name = "alice";
}

demonstrateTDZ();

// A subtler case: class declarations also have TDZ
try {
  const obj = new MyClass(); // ReferenceError: class not yet initialised
} catch (e) {
  console.log("Class TDZ:", (e as Error).message);
}

class MyClass {
  value = 42;
}

class declarations (unlike function declarations) are not hoisted with their definition. You cannot use a class before its declaration line, even in the same scope.

Fix: Always declare before use. Enable "no-use-before-define" in your ESLint config to catch this statically.

Trap 8: Object Key Gotchas

Problem 1: All object keys are strings (or Symbols). Numeric keys are coerced to strings.

Typescript
const obj: Record<string, string> = {};
obj[1] = "one";
obj[1.5] = "one point five";

console.log(Object.keys(obj)); // ['1', '1.5']: numbers become strings

Problem 2: If you need non-string keys, use Map.

Typescript
const map = new Map<object, string>();
const key1 = { id: 1 };
const key2 = { id: 1 }; // Different reference

map.set(key1, "first");
map.set(key2, "second");

console.log(map.get(key1)); // "first"
console.log(map.get(key2)); // "second": different key reference
console.log(map.size); // 2

Problem 3: Object spread and Object.assign only copy own enumerable properties. They do not copy prototype methods.

Typescript
class Point {
  constructor(public x: number, public y: number) {}
  toString(): string {
    return `(${this.x}, ${this.y})`;
  }
}

const p = new Point(1, 2);
const copy = { ...p };

console.log(copy.x); // 1: own property, copied
// copy.toString(): TypeError: copy.toString is not a function
// toString is on Point.prototype, not on p itself

Quick Reference

TrapWrong PatternRight Pattern
Async orderingAssume setTimeout(fn,0) is immediateMicrotasks run first; macrotasks last
Loop closuresvar in closuresUse let
MutationModify argument objectsReturn new objects
NaN checkx === NaN or isNaN(x)Number.isNaN(x)
Float comparison0.1 + 0.2 === 0.3Use epsilon or integer arithmetic
Equality===== (except x == null)
TDZUse let/const/class before declarationDeclare first
Object keysUse objects as Map keysUse Map

Key insight: These traps exist because JavaScript was designed with coercion and flexibility as first principles, and TypeScript layers type safety on top but cannot change the runtime. Knowing these traps is what separates engineers who understand the runtime from those who only understand the types.