Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~29 min· topic 6 of 8

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.

quiz.jswhole filejavascript
console.log(a);      // undefined
console.log(greet()); // "hi"
console.log(b);      // ReferenceError (TDZ)
var a = 1;
let b = 2;
function greet() { return "hi"; }
Run the creation phase on paperdiagram
Rendering diagram…

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.

Find each name on the scope chaindiagram
Rendering diagram…

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.

Decide this from the call sitediagram
Rendering diagram…

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. 1

    Swap var for let

    In the first example, change var a = 1; to let a = 1;. Predict the first console line now, then run. Which error message appears?

  2. 2

    Fix the var loop without let

    In the second example, change only the var k loop so it prints 0 1 2, without using let. Hint: setTimeout passes extra arguments to the callback: setTimeout(fn, 0, k).

  3. 3

    Make the script strict

    In the third example, add "use strict"; as the first line. Predict which lines change (look for every place window was the answer).

Code & diagrams

Hoisting and scope: predict each line New tab

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

Press Run to see the page.

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
Closures in loops: var versus let ES2015+ New tab

`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

Press Run to see the page.

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
this: the call site decides ES2022+ New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Asha
window
undefined
Ravi
Mei
true Obj
lost this Save
Classic mix: shadowing, typeof and named function expressions New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

undefined
120 undefined
inside 2
outside 1
hello
2

Break 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";.

terminal
$ Console
── what you'll see ──
Uncaught ReferenceError: Cannot access 'sayHi' before initialization

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++.

terminal
$ Console
── what you'll see ──
The method runs with the wrong this: window (sloppy), undefined (strict, then "Cannot read properties of undefined"), or the button element for listeners.

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 let bindings are created by the spec's CreatePerIterationEnvironment: the new binding is copied from the previous one before the update expression runs, which is why for (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 inside if blocks.

  • ▸

    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-level var and function declarations in classic scripts become properties of the global object; top-level let/const/class live in a separate declarative record and are not properties of window. In modules, nothing at top level becomes global.

Remember this

  1. 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. var names are created and set to undefined; function declarations are created with the whole function, so you can call them before the line they're written on; let, const and class names are created but uninitialised. Reading them before their line throws ReferenceError: 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 f is undefined, so calling it early gives TypeError: f is not a function.

  2. 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. var is scoped to the whole function (blocks like if and for don't contain it); let and const are 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 why console.log(x); var x = 1; inside a function prints undefined even when a global x exists.

  3. 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: with for (var i ...), all callbacks share one i, which is 3 by the time they run; with for (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 passes i in as a parameter.

  4. 4

    **this is decided by the call, not the definition** (except arrows). The rules, from strongest to weakest: (1) new Fn() sets this to the new object; (2) explicit: fn.call(obj), fn.apply(obj), or a function made with fn.bind(obj) (bound functions ignore later call/apply); (3) implicit: obj.fn() sets this to obj, the thing before the dot at call time; (4) default: a plain call fn() gives undefined in strict mode (modules, classes, "use strict") and the global object window in sloppy scripts. Arrow functions have no this of their own: they use the this of the scope where they were written, and call/bind can't change it.

  5. 5

    **Losing this** is the classic bug: const f = user.greet; f() or setTimeout(user.greet, 100) or button.addEventListener("click", obj.handle) all call the function without the dot, so rule 4 applies (and for listeners, this becomes the element). Fixes: wrap it in an arrow (() => user.greet()), use bind, or define the method as an arrow class field (handle = () => {...}). An arrow used as an object-literal method is the opposite mistake: this comes from outside the object, not the object.

  6. 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

01

Explain hoisting for var, let/const and function declarations, including the temporal dead zone.

02

Why does for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) print 3, 3, 3, and how do you fix it?

03

What is a closure? Give a real use.

04

List the rules that decide this, in order of priority.

05

Why do arrow functions behave differently with this, and when is that a problem?

Practice

01

Predict the output, then explain: var a = 1; function f() { console.log(a); var a = 2; console.log(a); } f(); console.log(a);

02

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).

03

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 this bound 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 with super.

  • ↔

    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, const and functions before their declarations.

  • I can trace a name through the scope chain, including shadowing.

  • I can explain and fix the var loop closure bug three ways.

  • I can determine this for any call using the four rules plus arrows.

  • I can explain my reasoning out loud for an output question.