Role: Frontend Engineer · Competency: JavaScript Runtime Fundamentals · Track: JS · Est. time: 3–4 hours
TICKET: JS-3001
TITLE: A utility module returns "wrong" values under certain inputs
PRIORITY: P2
TYPE: Bug / Investigation
DESCRIPTION: A shared utility behaves inconsistently: numbers turn into strings,
a counter shares state it shouldn't, and `this` is undefined in a
callback. The real cause is misunderstanding how the JavaScript
runtime handles types, scope, closures, and `this`. Investigate and
fix from first principles.
ACCEPTANCE CRITERIA:
- You can explain JS types, truthiness, and coercion, and avoid coercion bugs
- You can reason about scope (function/block), hoisting, and the TDZ
- You can explain and use closures deliberately
- You can predict and fix `this` binding in functions and callbacks
The "weird" bugs that make people distrust JavaScript almost always come from a fuzzy model of the runtime: == coercion, var hoisting, accidental shared state, and this losing its binding in a callback. A frontend engineer who understands the runtime writes fewer bugs and debugs the rest in minutes.
- Distinguish JS's primitive types and reference types; reason about truthiness/coercion
- Explain scope, hoisting, and the temporal dead zone (TDZ)
- Use closures intentionally (and recognize accidental shared state)
- Predict
thisin methods, plain calls, arrow functions, and callbacks
Types & coercion. Primitives: string, number, boolean, null, undefined, bigint, symbol. Everything else is an object (including arrays and functions). == coerces operands; === does not — default to ===. Classic traps: '5' + 1 === '51' (string concat) but '5' - 1 === 4 (numeric); [] == false is true. Falsy values: false, 0, '', null, undefined, NaN — everything else is truthy.
Scope & hoisting. var is function-scoped and hoisted (initialized undefined); let/const are block-scoped and live in the temporal dead zone from the top of the block until declared — touching them early throws. Prefer const, then let; avoid var.
console.log(a); // undefined (var hoisted)
var a = 1;
console.log(b); // ReferenceError (TDZ)
let b = 2;Closures. A function "closes over" the variables in scope where it was defined, retaining access after the outer function returns:
function makeCounter() {
let count = 0; // private state
return () => ++count; // closure keeps `count` alive
}
const next = makeCounter();
next(); next(); // 1, 2 — state preserved, not shared globallyClosures power private state and memoization (L8). The accidental-shared-state bug: capturing a loop variable with var instead of let.
this. Determined by how a function is called, not where it's defined:
| Call form | this is |
|---|---|
obj.method() |
obj |
fn() (plain) |
undefined (strict) / global (sloppy) |
new Fn() |
the new instance |
| arrow function | inherited from the enclosing scope (lexical) — not rebindable |
fn.call/apply/bind(x) |
x |
The classic callback bug — arr.forEach(obj.method) loses obj — is fixed with an arrow (arr.forEach(x => obj.method(x))) or .bind(obj).
==coercion surprises; use===.varhoisting / loop-capture bugs; uselet/const.- Arrow vs regular function for
this: arrows can't be rebound — wrong choice breaks methods or callbacks.
Work through labs/lab-04-js-runtime.md. A Node script contains four planted runtime bugs (a coercion bug, a var loop-capture bug, an accidental shared-state closure, and a this-losing callback). You'll predict each output before running, run it to confirm, and fix it — with Node unit tests verifying the corrected behavior.
For each buggy function, write down your predicted output, then run it in Node. Where prediction ≠ reality, that gap is exactly your runtime misconception — name it precisely (coercion? hoisting? this?). Record the prediction, the actual, and the root concept for each.
Ask an AI to explain why one of the buggy snippets misbehaves. Verify by running it yourself and reading the output — AIs sometimes give a correct-sounding explanation for the wrong reason. Log the precise runtime concept at fault, in your own words.
Fix all four bugs from first principles (not by trial and error). Submit, for each: predicted vs actual output, the root concept, the fix, and the passing test. Add a short "runtime rules I now follow" list (===, const/let, arrow-vs-regular for this).
Write a once(fn) higher-order function that uses a closure to ensure fn runs at most once (caching its result). Explain how the closure provides the private "has it run?" state.
- Types/coercion explained;
===used by default - Scope, hoisting, and TDZ reasoned about correctly
- Closures used intentionally; accidental shared state identified
-
thispredicted correctly across call forms; callback binding fixed - All four fixes pass Node tests
Which runtime behavior had you been working around without understanding? Now that you can predict it, what class of bug disappears from your code?