I1 · Part 2 · Intermediate
Scope & functions, properly
The corners of how a scope is built, new versus bind, and what you build once functions are values: currying, composition, recursion.
08On this page
Where this picks up
Scope covered the lookup itself — every kind of scope, the chain walked outward, shadowing, and writes that find nothing. This chapter starts where those rules stop being enough on their own: the corners of how a scope gets built, then what you build once functions are ordinary values.
Where the creation phase gets strange
Execution context gave the normal rules: what each declaration holds before its line runs. These are the cases those rules do not settle on their own — the ones that separate "I know hoisting" from "I know how a scope is built".
A function declaration inside a block
let in a block is block-scoped. A
function declaration in a block is, too — but only in
strict mode. In old-style sloppy scripts, browsers kept a legacy
behaviour the spec now writes down as a special case: the name is
also declared on the enclosing function like a
var, holding undefined until the block runs.
// sloppy script
f(); // TypeError: f is not a function — f exists, holds undefined
{ function f() { return "ok"; } }
f(); // "ok" — running the block copied it out
// strict mode, or any ES module
{ function g() { return "ok"; } }
g(); // ReferenceError — g never left its block
Same source, two behaviours, depending on a mode flag you may not
have set yourself (modules and class bodies are always strict). The
practical rule: if a function has to be visible outside a block,
declare it outside the block — or assign a function expression to a
let that is.
Default parameters get their own scope
Functions showed the first rule:
parameters are set up left to right, each in its TDZ until its turn,
so function f(a = b, b = 1) throws a
ReferenceError while b = a + 1 works.
Less well known: once any parameter has a default, the parameter list
becomes a scope of its own, sitting between the outer scope
and the body. A default cannot see vars declared in the
body, even ones with the same name.
var y = "outer";
function g(read = () => y) {
var y = "inner";
return read();
}
console.log(g()); // what happens?
"outer". The arrow was created in the parameter scope, so
its chain goes parameters → outer, skipping the body entirely.
The body's var y is a different binding that the arrow
can never reach.
A named function expression's name is private
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1); // fact is visible in here
};
factorial(5); // 120
typeof fact; // "undefined" — and nowhere out here
The engine puts fact in a tiny scope wrapped around the
function alone. That makes it the safe way to recurse — it still works
if someone reassigns factorial — and the name shows up in
stack traces. The binding is read-only: fact = 5 inside
the function is silently ignored in sloppy mode and a
TypeError in strict mode.
What the spec actually calls these boxes
The beginner chapter drew one "variable environment" per context. The spec splits it in two, and the split explains everything above:
-
VariableEnvironment — where
vars and (sloppy) function declarations go. Set once when the function starts, never changes. That is whyvarignores blocks. -
LexicalEnvironment — where
let,constandclassgo. Every time execution enters a block, a new environment is created and becomes the current LexicalEnvironment, with the old one as its outer reference; leaving the block restores it. That is block scope, and it is also how each loop iteration gets its ownlet i.
Each environment is an environment record plus that outer
reference. The global one is a two-part record: an object part that
is window (for var and function
declarations) and a plain part beside it (for let and
const). That one detail is why top-level
let is not on window, yet two classic scripts
declaring the same let still collide.
Closures, and what this chapter adds to them
The scope chain is the whole mechanism behind closures: a function keeps its outer reference, and if the function outlives the call, the environment on the other end of that reference cannot be collected. The closures chapter covers that properly — the live link rather than a snapshot, one closure per call, the five jobs they do, and the memory they hold on to.
What is worth adding here: a closure is not a different kind of scope. It is the same lookup you just read about, still working after the function that created it has returned. Shadowing behaves identically inside one, and a closure over a name that gets shadowed later still sees the binding that was in scope where it was written.
this, past the four rules
The beginner chapter on this has the
four binding rules and the ways a method loses its object. What
belongs here is the part that is really about scope: an arrow has no
this of its own, so it resolves the name through the
scope chain like any other variable — which is why an arrow written
inside a method keeps that method's this, and an arrow
written at the top level never can.
And one experiment worth running, because it settles the ranking for
good — new against a this that was already
welded on by bind:
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 favour
of the newly created one. new beats explicit beats
everything else, confirmed by experiment rather than by table.
Two properties every function carries
Functions are objects, and two of their properties matter below:
length is what curry reads to decide whether
it has enough arguments yet.
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.
Dry run: curried(2)(3)(4), call by call
| Call | args accumulated so far | args.length vs fn.length (3) | What happens |
|---|---|---|---|
curried(2) | [2] | 1 < 3 — not enough | returns a new function closing over [2] |
(3) | [2, 3] | 2 < 3 — not enough | returns another function closing over [2, 3] |
(4) | [2, 3, 4] | 3 >= 3 — enough | fn.apply(this, [2, 3, 4]) — volume(2, 3, 4) runs, returns 24 |
Each intermediate call's args is a closure the next call
reads and extends with .concat(more) — this is the exact
same "environment survives the call that made it" mechanism from the
closures chapter, just used to accumulate arguments instead of a
counter.
// 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 incThenDouble = compose(double, inc); // double(inc(x))
incThenDouble(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, as higher-order functions
A callback is the same idea as everything above it in this chapter —
a function treated as a value and handed to other code. The only
difference is who calls it and when.
arr.map(fn) calls yours immediately;
setTimeout(fn) parks it.
The historical shape — Node's error-first convention, the nested
staircase it produced, and why try/catch could not reach
inside it — is covered where it belongs, next to the promises that
replaced it, in
Callbacks, then promises. Worth
knowing here: a promise does not remove the callback. It just gives
you somewhere to hand it that can be chained and can fail in one
place.
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.
Say it like this → "Recursion needs a base
case that's actually reachable and a step that provably shrinks
toward it. If I'm not sure the depth is bounded, I either convert it
to a loop or add an explicit depth guard — I don't rely on TCO,
because V8 doesn't have it."
Interview answer, one sentence
"Scope is fixed by where code is written, a closure keeps that scope alive, this is decided by how a function is called, and higher-order functions — currying, partial application, composition — build new functions out of old ones."
Before you move on
- Explain why a
functiondeclared inside a block is callable outside it in sloppy mode but not in strict mode or a module. - Say why a default parameter can't see a
vardeclared later in the function body, in terms of the parameter scope. - Trace
curry(volume)throughcurried(2)(3)(4), writing down the accumulatedargsat each call. - Explain why
new bound()overrides athisalready welded on bybind, and what that says about the binding rule ranking. - Say why unbounded recursion is a real risk in JS specifically, given that most engines don't implement tail-call optimization.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series