Scope & functions, properly
Closures and this — the two ideas most interviews spend the most time on.
The scope chain
Every function remembers the scope it was written in, not the scope it's called from — that's what "lexical" means. Looking up a name walks outward through that chain, one level at a time, until it finds a match or runs out of scopes.
let city = "Pune";
function outer() {
let name = "Ana";
function inner() {
let age = 29;
console.log(name, city); // finds "name" one level out, "city" two levels out
}
inner();
}
The chain is built from where the function sits in the source — nesting on the page, not the order things get called in. A function called from somewhere far away still only ever sees its own lexical chain, never the caller's local variables.
Shadowing, briefly revisited
A name declared in an inner scope hides — doesn't overwrite — the same name further out. Once you leave the inner scope, the outer binding is exactly as it was.
let x = "outer";
function show() {
let x = "inner";
console.log(x);
}
show();
console.log(x); // what happens?
"inner", then "outer" — two completely
separate bindings that happen to share a name. This is also why
reusing a loop variable name inside nested loops is safe: each
let i in its own block shadows the one outside it.
Closures
A closure isn't a special syntax — it's just what already happens every time an inner function outlives the call that created it. The inner function keeps a live link to its outer variables, not a snapshot of their values at the time.
function makeCounter() {
let count = 0;
return {
inc: () => ++count,
get: () => count,
};
}
const counter = makeCounter();
counter.inc();
counter.inc();
console.log(counter.get()); // what happens?
2. makeCounter already returned — normally
its local variables would be garbage collected the moment the
function exits. But inc and get both still
reference count, so the engine keeps that one variable
alive for as long as something can still reach it. Call
makeCounter() again and you get a brand new,
completely independent count — the closure
belongs to that specific call, not to the function definition.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);
logs 3, 3, 3 — every callback closes over the exact same
var i, and by the time any of them run, the loop has
already finished and i is 3. Switch
var to let and it logs 0, 1, 2,
because let creates a fresh binding per iteration
— each callback closes over its own copy.
Five real jobs closures do
This is the part interviews actually probe — not "what is a closure" but "build me one of these":
// 1. Factories — a function that builds customized functions
function multiplierOf(factor) {
return (n) => n * factor;
}
const double = multiplierOf(2);
double(5); // 10 — "factor" is remembered inside double, permanently
// 2. Privacy — variables no outside code can ever touch directly
function createAccount(startingBalance) {
let balance = startingBalance; // truly private — no "this.balance" to poke at
return {
deposit: (n) => (balance += n),
getBalance: () => balance,
};
}
// 3. Memoize — cache a function's results by its arguments
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
let calls = 0;
const slowSquare = memoize((n) => { calls++; return n * n; });
slowSquare(5);
slowSquare(5);
slowSquare(5);
console.log("real calls:", calls); // what happens?
1 — the underlying function only ever runs once for a
given set of arguments. cache is closed over by the
returned function and nothing else, so every call checks the same
Map without any outside code able to reach or corrupt it.
// 4. Once — guarantee a function's real work happens a single time
function once(fn) {
let called = false, result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
let inits = 0;
const init = once(() => { inits++; return "ready"; });
console.log(init(), init(), init());
console.log("actual inits:", inits); // what happens?
ready ready ready, then 1. Every call after
the first returns the same cached result without
re-running fn — the standard shape behind "run this setup
code exactly once, no matter how many times it's requested."
// 5. Debounce & throttle — closures managing a timer nobody outside can see
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, interval) {
let ready = true;
return function (...args) {
if (!ready) return;
ready = false;
fn.apply(this, args);
setTimeout(() => { ready = true; }, interval);
};
}
this — five binding rules, ranked
this isn't decided by where a function is written — it's
decided at call time, by how the function is called.
Four separate rules can set it, and they have a strict pecking order:
| Rank | Rule | Trigger | this becomes |
|---|---|---|---|
| 1 (wins) | new binding | new Fn() | the brand-new object being constructed |
| 2 | Explicit binding | fn.call(obj), .apply(obj), .bind(obj) | whatever object you handed it |
| 3 | Implicit binding | obj.method() | the object left of the dot |
| 4 (default) | Default binding | a plain fn() call | undefined in strict mode / modules (the global object in old-style sloppy scripts) |
Arrows are the exception that sits outside this whole table — they
never bind their own this at all, so none of these four
rules ever apply to one directly; they just read this
from whichever scope they were written in, same as any other
variable.
const obj = {
name: "obj",
whoAmI() { return this.name; },
};
console.log(obj.whoAmI()); // implicit — what happens?
const detached = obj.whoAmI;
try {
console.log(detached()); // default — what happens?
} catch (e) {
console.log("threw:", e.message);
}
"obj", then a TypeError. Assigning
obj.whoAmI to detached copies the
function, not the object it was attached to — called bare,
as detached(), there's no object left of a dot, so
default binding kicks in and this is undefined.
this.name on undefined throws. This exact
bug is why onClick={someObj.method}-style callbacks
quietly lose their this unless bound first.
call, apply, bind
Sets this to… |
Runs the function? | Arguments | |
|---|---|---|---|
fn.call(obj, a, b) | obj | immediately | listed one by one |
fn.apply(obj, [a, b]) | obj | immediately | as a single array |
fn.bind(obj, a) | obj, permanently | never — returns a new function | a is pre-filled; more can be added at the real call |
function whoAmI() { return this === undefined ? "still stuck" : this.tag; }
const F = function () { return this; };
const bound = F.bind({ tag: "bound" });
const created = new bound(); // new vs bind — who wins?
console.log(created instanceof bound, created.tag); // what happens?
true undefined — even a this locked in by
bind gets overridden the moment the bound function is
called with new. It still constructs a real, correctly-typed
instance; the bound object is just discarded in favor of the newly
created one. That's the precedence table above, confirmed:
new beats explicit beats everything else.
IIFE — the closure that runs itself
const counter = (function () {
let count = 0; // invisible outside this expression
return { inc: () => ++count };
})();
Before ES modules existed, every script shared one global scope — wrapping code in an Immediately Invoked Function Expression was the only way to get a private scope of your own, with just the return value exposed. Modules made that automatic, so IIFEs are rare in new code — but the pattern (function scope as a privacy boundary) is exactly what closures 1 and 2 above are still doing today.
function labeled(a, b = 1, ...rest) {}
labeled.length; // 1 — counts params up to the FIRST one with a default or rest
labeled.name; // "labeled"
const anon = () => {};
anon.name; // "anon" — inferred from the variable it's assigned to
Higher-order functions: currying, partial application, composition
A higher-order function just means: takes a function as an
argument, returns one, or both.
map/filter/reduce from
earlier already qualify — this section is what you build with that
idea once you're the one writing the higher-order function.
// Currying — one arg at a time, until there are enough
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) return fn.apply(this, args);
return (...more) => curried.apply(this, args.concat(more));
};
}
function volume(l, w, h) { return l * w * h; }
const curried = curry(volume);
console.log(curried(2)(3)(4)); // what happens?
console.log(curried(2, 3)(4)); // what happens?
console.log(curried(2, 3, 4)); // what happens?
All three print 24 — currying doesn't change
what gets computed, only how many calls it takes to supply
the arguments. Each call checks whether it has enough arguments yet
(fn.length, from just above); if not, it returns another
function waiting for the rest.
// Partial application — curry's simpler cousin: some args now, the rest later, ONE split
function partial(fn, ...preset) {
return (...rest) => fn(...preset, ...rest);
}
function greet(greeting, name) { return greeting + ", " + name + "!"; }
const hiTo = partial(greet, "Hi");
hiTo("Ana"); // "Hi, Ana!"
// Composition — chain small functions into one, right to left
function compose(...fns) {
return (x) => fns.reduceRight((acc, fn) => fn(acc), x);
}
const double = (x) => x * 2;
const inc = (x) => x + 1;
const doubleThenShowOldValueIncremented = compose(double, inc); // double(inc(x))
doubleThenShowOldValueIncremented(5); // (5 + 1) * 2 = 12
compose reads right to left because that's the order a
nested call double(inc(x)) actually runs in — the
rightmost function touches x first. Some libraries offer
a pipe instead, which is the identical idea left to
right — purely a readability choice, same
reduce/reduceRight underneath.
Callbacks and the hell they used to cause
Before promises, "do this, then when it's done do that" meant passing
a function to be called later. Node standardized the shape:
error-first — the callback's first parameter is always either
an error or null.
function readConfig(callback) {
fs.readFile("config.json", (err, data) => {
if (err) return callback(err); // error path checked FIRST, always
callback(null, JSON.parse(data));
});
}
The trouble starts once one async step needs another, which needs another — each nested one level deeper, error handling repeated at every level:
getUser(id, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getInvoice(orders[0].id, (err, invoice) => {
if (err) return handleError(err);
render(invoice); // four levels deep and still growing sideways
});
});
});
That rightward staircase is "callback hell" — not a formal term, just what everyone called code that could only grow by indenting further. Promises (next chapter) fix the shape without changing the underlying idea: still "run this later," just chainable instead of nested.
Recursion
A function that calls itself, always working toward a base case — the condition that stops it. Skip the base case, or get the shrinking step wrong, and it never stops on its own.
function factorial(n) {
if (n <= 1) return 1; // base case — where it stops
return n * factorial(n - 1); // recursive step — smaller problem, same shape
}
factorial(5); // 120
function countDown(n) {
if (n <= 0) return "done";
return countDown(n - 1);
}
try {
console.log(countDown(100000)); // what happens?
} catch (e) {
console.log("threw:", e.constructor.name, "-", e.message);
}
On most engines, a RangeError: Maximum call stack size
exceeded — each pending call sits on the call stack waiting
for the one below it to return, and the stack has a hard size limit.
The spec technically allows tail-call optimization (reusing
the current frame when the recursive call is the very last thing a
function does), which would make this run in constant stack space —
but outside Safari, no major engine actually implements it. In
practice: deep, unbounded recursion is a real risk in JS, not just a
theoretical one. A loop has no such ceiling.
Opens in the editor — write it, run it, and check it against real tests.