JavaScript Pitfalls and Contraexamples
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
asynccallback where nothing awaits it. - You treat every copy as shallow until you have copied each level on purpose.