Topic 15.6
Interview Deep Dive: Scope, Closures, this and Hoisting
In one line
Most "what does this print?" interview questions test four rules: how hoisting sets up variables before code runs, how lexical scope decides which variable a name means, how closures keep variables alive, and how a call decides this.
Think of it like this
A theatre before the show. Before the curtain rises, the stage manager walks the script and puts a name card on every chair that will be used (hoisting). var chairs get a card saying "empty" (undefined); let and const chairs get a card saying "do not sit yet" (the TDZ); function declarations get the actual actor seated already. During the show, an actor who needs a prop looks in their own room first, then the corridor, then the whole building (the scope chain). And who "I" refers to in a line depends on who is speaking at that moment (this).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Execution context
- The environment the engine creates to run a script or a function call: its variables, scope chain and
this. - Hoisting
- Declarations being set up at the start of their scope, before any line of it runs.
- Temporal dead zone (TDZ)
- The time between the start of a scope and the line declaring a
let/const, when using the name throws an error. - Lexical scope
- The rule that a name's meaning depends on where the code is written, not where it is called.
- Scope chain
- The list of scopes the engine searches, from the inside outwards, to find a name.
- Shadowing
- An inner variable with the same name as an outer one, hiding the outer one inside its scope.
- Closure
- A function together with the variables it remembers from the scope where it was created.
- this
- A special value set by how a function is called: the object before the dot, a new object, an explicit choice, or undefined/window.
- Sloppy mode
- The older, non-strict way JavaScript runs classic scripts, with some forgiving and confusing rules.
Step by step
01Run the creation phase on paper
For this snippet, list the names first: a is undefined, b is in the TDZ, greet is the whole function. Now each line's result is predictable.
console.log(a); // undefined
console.log(greet()); // "hi"
console.log(b); // ReferenceError (TDZ)
var a = 1;
let b = 2;
function greet() { return "hi"; }02Find each name on the scope chain
When a function uses a name, look in its own scope first (including its hoisted vars), then the scope it was written in, then outwards. The first match wins.
03Spot what a closure captures
A closure captures the variable, not the value at that moment. Ask: "how many copies of this variable exist?" One var i for the whole loop means every callback reads the same, final value.
04Decide this from the call site
Look at the line that calls the function. new? The new object. call/apply/bind? The given object. obj.fn()? obj. A bare fn()? undefined (strict) or window (sloppy). Arrow? Skip all that and look outward to where it was written.
05Explain your answer
A strong interview answer names the rule: "var is hoisted and initialised to undefined", "let makes a new binding per iteration", "the method was detached, so this is the default binding". Then give the fix.
Try it yourself
- 1
Swap var for let
In the first example, change
var a = 1;tolet a = 1;. Predict the first console line now, then run. Which error message appears? - 2
Fix the var loop without let
In the second example, change only the
var kloop so it prints0 1 2, without usinglet. Hint:setTimeoutpasses extra arguments to the callback:setTimeout(fn, 0, k). - 3
Make the script strict
In the third example, add
"use strict";as the first line. Predict which lines change (look for every placewindowwas the answer).
Code & diagrams
Inside `show`, the local `var x` is hoisted to the top of the function and shadows the global one, so the first line reads the local `x` while it is still `undefined`. `inner` reads `x` from where it was written (inside `outer`), not from the global scope.
Preview
Console
Nothing logged yet.Expected console output
undefined
ReferenceError: Cannot access 'b' before initialization
function declarations are hoisted whole
TypeError: notYet is not a function
undefined
outer
var ignores blocks undefined`var k` is one variable shared by all three callbacks; the loop has finished (k is 3) before any timer runs. `let m` gets a fresh binding each iteration. The IIFE copies `n` into a new parameter per call, which is how this was fixed before ES2015.
Preview
Console
Nothing logged yet.Expected console output
[ 3, 3, 3 ]
[ 0, 1, 2 ]
2 1
var k: 3
var k: 3
var k: 3
let m: 0
let m: 1
let m: 2
IIFE n: 0
IIFE n: 1
IIFE n: 2`loose()` has no dot, so sloppy mode uses `window`; strict code and class bodies use `undefined`. A bound function ignores a later `call`. The arrow in the object literal took `this` from the top level of the script (`window`), not from `obj`. The arrow class field keeps `this` even when detached.
Preview
Console
Nothing logged yet.Expected console output
Asha
window
undefined
Ravi
Mei
true Obj
lost this Save`typeof` is the one safe way to touch an undeclared name. The name `f` of a named function expression exists only inside that function. In the IIFE, `var greeting` is hoisted, so the assignment changes the local variable and the global keeps `hello`.
Preview
Console
Nothing logged yet.Expected console output
undefined
120 undefined
inside 2
outside 1
hello
2Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Call a function expression too early
Move a call sayHi(); above const sayHi = () => "hi";.
Break #2
Pass a method as a callback
Write setTimeout(user.whoAmI, 0) and log the result inside, or use button.addEventListener("click", counter.increment) where increment uses this.count++.
Myth vs fact
Myth
let and const are not hoisted.
Fact
They are hoisted (the scope knows about them from the start, which is why they shadow outer names from the first line), but they stay uninitialised until their declaration runs: the TDZ.
Myth
this refers to the function itself, or to where the function was defined.
Fact
For normal functions, this is set by how the function is called. Only arrow functions take this from where they are written.
Myth
Closures copy the values of outer variables.
Fact
They keep a live reference to the variables. If the variable changes later, the closure sees the new value; that is exactly the var loop bug.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Per-iteration
letbindings are created by the spec'sCreatePerIterationEnvironment: the new binding is copied from the previous one before the update expression runs, which is whyfor (let i = 0; i < 3; i++)closures see 0, 1, 2. - ▸
In sloppy mode, function declarations inside blocks have legacy "Annex B" semantics: they also create a
var-like binding in the enclosing function, and engines must keep this for old code. In strict mode they are block-scoped. Avoid declaring functions insideifblocks. - ▸
Engines don't keep every outer variable alive: V8 analyses which variables inner functions actually reference and stores only those in the closure's context. But all closures created in the same scope share one context, so one closure referencing a big object keeps it alive for all of them.
- ▸
globalThis, top-levelvarand function declarations in classic scripts become properties of the global object; top-levellet/const/classlive in a separate declarative record and are not properties ofwindow. In modules, nothing at top level becomes global.
Remember this
- 1
Execution context and hoisting. Before running a script or function, the engine does a creation phase: it finds every declaration in that scope and creates its binding.
varnames are created and set toundefined; function declarations are created with the whole function, so you can call them before the line they're written on;let,constandclassnames are created but uninitialised. Reading them before their line throwsReferenceError: Cannot access 'x' before initialization. That gap between the start of the scope and the declaration is the temporal dead zone (TDZ). Function expressions (var f = function () {}) follow their variable's rule:var fisundefined, so calling it early givesTypeError: f is not a function. - 2
Lexical scope. A scope is the region where a name is visible. JavaScript uses lexical (static) scope: what a name refers to depends on where the code is written, not where it is called from. The engine looks the name up in the current scope, then the enclosing one, outward to the global scope: the scope chain.
varis scoped to the whole function (blocks likeifandfordon't contain it);letandconstare scoped to the nearest block{ }. A local declaration shadows (hides) an outer one with the same name for its whole scope, including the lines before it, which is whyconsole.log(x); var x = 1;inside a function printsundefinedeven when a globalxexists. - 3
Closures. A closure is a function plus the scope it was created in. When an inner function is returned or passed somewhere, it keeps a live link to the outer variables (not a copy of their values). Each call of the outer function creates a new scope, so two counters made by
makeCounter()don't share a count. The famous loop question: withfor (var i ...), all callbacks share onei, which is3by the time they run; withfor (let i ...), the spec creates a fresh binding per iteration, so each callback sees its own value. The pre-2015 fix was an IIFE that passesiin as a parameter. - 4
**
thisis decided by the call, not the definition** (except arrows). The rules, from strongest to weakest: (1)new Fn()setsthisto the new object; (2) explicit:fn.call(obj),fn.apply(obj), or a function made withfn.bind(obj)(bound functions ignore latercall/apply); (3) implicit:obj.fn()setsthistoobj, the thing before the dot at call time; (4) default: a plain callfn()givesundefinedin strict mode (modules, classes,"use strict") and the global objectwindowin sloppy scripts. Arrow functions have nothisof their own: they use thethisof the scope where they were written, andcall/bindcan't change it. - 5
**Losing
this** is the classic bug:const f = user.greet; f()orsetTimeout(user.greet, 100)orbutton.addEventListener("click", obj.handle)all call the function without the dot, so rule 4 applies (and for listeners,thisbecomes the element). Fixes: wrap it in an arrow (() => user.greet()), usebind, or define the method as an arrow class field (handle = () => {...}). An arrow used as an object-literal method is the opposite mistake:thiscomes from outside the object, not the object. - 6
How to answer output questions. Read the code twice. First pass: run the creation phase yourself, writing down each name and its starting value (
undefined, TDZ, or function). Second pass: run line by line, and for every function call ask "which scope is this name from?" and "how is this function called?" Say your reasoning out loud; interviewers value the rules more than the answer. Phase 3 (functions and closures) and Phase 7 (this) cover each rule in depth.
Explain it without notes
Explain hoisting for var, let/const and function declarations, including the temporal dead zone.
Why does for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) print 3, 3, 3, and how do you fix it?
What is a closure? Give a real use.
List the rules that decide this, in order of priority.
Why do arrow functions behave differently with this, and when is that a problem?
Practice
Predict the output, then explain: var a = 1; function f() { console.log(a); var a = 2; console.log(a); } f(); console.log(a);
Predict the output: const o = { n: 1, get() { return this.n; } }; const g = o.get; console.log(o.get(), g.call({ n: 5 }), (o.get)(), (0, o.get)()); (classic sloppy script, and assume window.n is undefined).
Write once(fn) that returns a function which runs fn only the first time and returns the first result on every later call, keeping this and arguments.
Trade-offs
- ↔
Arrow class fields keep
thisbound for callbacks, but each instance gets its own copy of the function instead of sharing one on the prototype, and subclasses can't call them withsuper. - ↔
Closures give cheap private state, but long-lived closures (listeners, timers, caches) keep everything they reference alive; forgetting to remove them is a common memory leak (Topic 14.8).
Done when you can
I can predict the result of using
var,let,constand functions before their declarations.I can trace a name through the scope chain, including shadowing.
I can explain and fix the
varloop closure bug three ways.I can determine
thisfor any call using the four rules plus arrows.I can explain my reasoning out loud for an output question.