Error handling & debugging
The Intermediate track's close-out — past what B8 already covered.
The first pass at errors covered
try/catch/finally, custom Error subclasses,
and reading a stack trace. This is what's past that: chaining errors
together, what happens to a rejection nobody catches, why
immutability keeps coming up in framework code, and debugging tools
past console.log.
Error chaining with cause
Catching a low-level error and throwing a more meaningful one is
normal — but doing that used to destroy the original error entirely.
The cause option keeps it attached.
function loadUser() {
try {
JSON.parse("not valid json");
} catch (dbError) {
throw new Error("failed to load user", { cause: dbError });
}
}
try {
loadUser();
} catch (e) {
console.log(e.message);
console.log(e.cause.message); // what happens?
}
"failed to load user", then the original
SyntaxError's message. Without cause, that
original error is just gone — whoever's debugging this in production
sees "failed to load user" and has to guess why. With it,
e.cause carries the full original error (and its own
stack trace) all the way up, however many layers re-throw in between.
Unhandled promise rejections
A rejected promise with no .catch() anywhere in its
chain doesn't fail silently — it surfaces as a top-level
unhandledrejection event (the same mechanism
this site's own code runner listens
to, to show you an error even from code with no explicit
catch at all).
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled:", event.reason);
event.preventDefault(); // stops it from also logging as a browser console error
});
promise.then(() => { anotherAsyncCall(); }) — without a
return — lets anotherAsyncCall()'s promise
run completely detached from the outer chain. If it rejects,
no .catch() further down that outer chain will ever see
it; it becomes its own separate unhandled rejection.
Immutability — why frameworks care so much
React, Redux, and similar tools decide "did this change?" with a
single === check, not a deep comparison — because a
deep comparison of a large tree, on every single render, is far too
slow to do constantly.
const state1 = { count: 0 };
function mutateInPlace(state) {
state.count++;
return state;
}
function updateImmutably(state) {
return { ...state, count: state.count + 1 };
}
const afterMutate = mutateInPlace(state1);
console.log(state1 === afterMutate); // what happens?
const state2 = { count: 0 };
const afterUpdate = updateImmutably(state2);
console.log(state2 === afterUpdate); // what happens?
true, then false. Mutating in place changes
the same object — a === check comparing the old
reference to the new one sees no difference at all and a framework
built on that check will skip re-rendering, even though the data
genuinely changed. Building a fresh object every update
guarantees a new reference exactly when something actually changed —
which is the entire reason "don't mutate state directly" is a rule in
React, not just a style preference.
const frozen = Object.freeze({ a: 1 });
frozen.a = 2; // non-strict script: fails silently, "a" stays 1
// strict mode / modules (the normal case today): throws a TypeError
console.log(frozen.a); // 1 either way — the object never actually changed
Object.freeze is shallow —
it locks the object's own top-level properties, but a nested object
inside a frozen one is still fully mutable. It's a debugging aid for
catching accidental top-level mutation, not a deep-immutability
guarantee.
Debugging, past console.log
| Tool | For |
|---|---|
| A line-number breakpoint (Sources panel) | pause every time execution reaches that exact line |
| A conditional breakpoint | right-click the line number — pause only when an expression you type is true, e.g. user.id === 42. Essential once a bug only shows up for one specific input out of thousands. |
| A watch expression | pin any expression to re-evaluate and display at every pause, without retyping it in the console each time |
debugger; | a breakpoint written directly in the source — pauses there whenever DevTools is open, no manual click needed |
| The Network tab | every request's status, timing, headers, and actual response body — the first stop when data "never showed up" |
Once paused at any breakpoint, the call stack panel shows the exact
chain of calls that got you there — the same information a
.stack string gives you after the fact, except you can
now inspect every live variable at every level of it, not just read a
frozen snapshot of what the values were.
Opens in the editor — write it, run it, and check it against real tests.