JavaScript Pitfalls and Contraexamples

A catalog of the classic JavaScript traps, each shown as wrong code beside right code, with the rule that prevents the bug.

Coercion surprises

== bends types until something matches

The rule for every trap on this page is the same: run the wrong version first. Predict, execute, compare — a trap you have triggered once is a rule you own.

// WRONG — == coerces both sides until they compare equal:
console.log(1 == "1");      // true  — string coerced to number
console.log("" == 0);       // true  — empty string coerced to 0
console.log(null == 0);     // false — special case! null only equals undefined
console.log([] == false);   // true  — [] → "" → 0 → false

// RIGHT — === compares type first, then value:
console.log(1 === "1");     // false
console.log("" === 0);      // false
console.log(null === 0);    // false
console.log([] === false);  // false

The rule: always === (and !==). The single exception is idiomatic: x == null to test "null or undefined" at once.

+ and if — the two other coercion hotspots

// + prefers STRINGS if either side is one:
console.log("1" + 2);   // "12" — concatenation
console.log(1 + 2);     // 3
console.log(1 + true);  // 2 — true coerces to 1
console.log([] + {});   // "[object Object]" — both coerced to strings

// if coerces to boolean — the FALSY values are exactly six:
// false, 0, "", null, undefined, NaN
const input = "0";
if (input) console.log("truthy!"); // prints — "0" is a non-empty STRING, so truthy

// RIGHT: decide explicitly instead of letting coercion decide:
if (input !== null && input !== "") console.log("real input");

this: four ways to lose it

Extraction, callbacks, arrow functions, and events

// WRONG — extracting a method LOSES the receiver:
const counter = {
  count: 0,
  increment() { this.count++; }, // this = whoever calls it
};
const inc = counter.increment;
inc(); // this === undefined (strict mode) — TypeError, or a global `count`

// WRONG — passing a method as a callback does the same:
setTimeout(counter.increment, 0); // increments nothing of counter's

// RIGHT — bind once, or use an arrow (arrows have NO this of their own):
const incBound = counter.increment.bind(counter);
setTimeout(() => counter.increment(), 0); // the arrow defers to the surrounding this

Lesson 10's diagram holds: this is decided by the call, not the definition. Anywhere you detach a method from its object, plan to bind it or wrap it in an arrow.

Async and closure traps

async in a forEach — fire and forget, silently

// WRONG — forEach ignores the returned promise; nothing waits:
urls.forEach(async (url) => {
  const r = await fetch(url); // all in flight, order lost…
  save(r);                    // …and errors escape every try/catch around the loop
});

// RIGHT A — sequential when order matters:
for (const url of urls) {
  const r = await fetch(url); // one at a time
  save(r);
}

// RIGHT B — concurrent when it does not (lesson 20's rule):
await Promise.all(urls.map((url) => fetch(url)));

The trap is invisible: the code looks sequential and runs concurrently — the worst of both. Anything array-shaped that takes a callback (forEach, map, setTimeout) does not await; only loops and combinators do.

The closure-in-a-loop trap

// WRONG — var is function-scoped: ALL three callbacks share ONE i,
// and by the time they run, the loop finished: 3, 3, 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

// RIGHT — let is block-scoped: a fresh binding per iteration: 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

With let, every iteration gets its own i, so each closure captures a different binding. This single rule retires one of the most famous JavaScript interview traps — and explains why modern style bans var outright.

Mutation and shared references

Copies are shallow — one level deep, always

// WRONG — spread copied the ARRAY, not the OBJECTS inside it:
const original = [{ count: 1 }];
const copy = [...original];
copy[0].count = 99;
console.log(original[0].count); // 99 — same object, two paths to it (lesson 15)

// RIGHT — copy every level you intend to change:
const safeCopy = original.map((item) => ({ ...item })); // new objects, level by level

This is the same aliasing diagram from the heap lesson, wearing a bug costume. It bites hardest in the "state first, render second" pattern from the architecture lesson: mutate a "copy" and the framework sees no change, because the reference stayed identical. Immutable updates ({ ...state, page: 2 }) exist precisely to keep references honest.

Practice: trigger every trap

The task

Several of this lesson's wrong blocks are runnable in demo/practice/33_store_counter.html and the earlier phase demos. Rebuild two traps from memory — no peeking: (1) an async forEach that loses errors, then its two fixes; (2) a shallow copy that mutates the original, then the per-level fix. Predict each output before running.

The checklist

  • You default to === and can name the one idiomatic exception.
  • You bind or arrow-wrap any method that leaves its object.
  • You never pass an async callback where nothing awaits it.
  • You treat every copy as shallow until you have copied each level on purpose.