Variables, Scope & Hoisting Interview Questions & Answers
33 questions Updated 2026-06-17
Common JavaScript interview questions on var, let and const, scope, hoisting and the temporal dead zone, with clear answers and examples.
Read the in-depth guideJavaScript Variables, Scope & Hoisting — var, let & const ExplainedThey differ along three axes: scope, hoisting/initialization, and reassignment.
| Keyword | Scope | Hoisted? | Reassignable? | Redeclarable? |
|---|---|---|---|---|
var |
function | yes, init undefined |
yes | yes |
let |
block | yes, in TDZ (uninit) | yes | no |
const |
block | yes, in TDZ (uninit) | no | no |
The subtle one is const: it makes the binding constant, not the
value. You can't reassign the variable, but if it holds an object or array
you can still mutate the contents.
const user = { name: 'Ada' }
user.name = 'Grace' // OK — mutating the object the binding points to
user = {} // TypeError — reassigning the binding
const list = [1, 2]
list.push(3) // [1, 2, 3]
Modern guidance: default to const, use let when you genuinely need to
reassign, and avoid var.
Hoisting is the JavaScript engine processing declarations before executing code, so they behave as if "moved" to the top of their scope. But what gets hoisted differs:
vardeclarations are hoisted and initialized toundefined, so reading one before its assignment givesundefined(no error).- Function declarations are hoisted entirely — you can call them above where they're written.
let/constare hoisted but left uninitialized in the temporal dead zone; touching them early throws.
console.log(a) // undefined (var hoisted, value not yet assigned)
var a = 1
greet() // 'hi' (function declaration fully hoisted)
function greet() { return 'hi' }
console.log(b) // ReferenceError (TDZ)
let b = 2
Mental model: declarations are "registered" first; only the assignments run in place.
The TDZ is the window between entering a scope and the line where a
let/const variable is actually declared. The binding exists (it was
hoisted) but is uninitialized, so any attempt to read or write it in that
window throws a ReferenceError.
{
// TDZ for `x` starts here
console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x = 5 // TDZ ends — x is now initialized
console.log(x) // 5
}
It exists on purpose: it turns "used before declared" from a silent
undefined bug (the var behavior) into a loud, immediate error, which
catches mistakes earlier and makes const semantics sound.
Function scope (var) means a variable is visible anywhere inside the
enclosing function, regardless of which { } blocks it's nested in. Block
scope (let/const) confines a variable to the nearest pair of braces —
including if, for, while, and even a bare { } block.
function demo() {
if (true) {
var v = 'function-scoped'
let l = 'block-scoped'
}
console.log(v) // 'function-scoped' — var leaks out of the if
console.log(l) // ReferenceError — l only exists inside the if
}
Block scoping is generally safer: variables stay where they're relevant and
you avoid accidental leakage across a function — which is one big reason let/
const replaced var.
var lets you redeclare the same name in the same scope without
complaint — which silently masks bugs and accidental overwrites. let and
const throw a SyntaxError if you redeclare an identifier in the same
scope.
var x = 1
var x = 2 // allowed (and easy to do by accident)
let y = 1
let y = 2 // SyntaxError: Identifier 'y' has already been declared
Note this is about redeclaration in the same scope. You can still
shadow a let in a nested inner block with a new let of the same name —
that creates a separate binding and is perfectly legal.
At the top level of a script, a var declaration also creates a property
on the global object (window in browsers, globalThis generally). let and
const do not — they create bindings in a separate script-scoped record
that the global object can't see.
var a = 1
let b = 2
const c = 3
window.a // 1 <- var leaked onto the global object
window.b // undefined <- let did not
window.c // undefined
This is another reason to avoid top-level var: it pollutes the global
namespace and can clash with other scripts or built-ins. (Inside ES modules,
even top-level var doesn't attach to the global object, since module scope
isn't global scope.)
Because var is function-scoped, the loop reuses one single binding
across all iterations. Any callbacks created in the loop close over that same
variable, so when they run later they all read its final value. let
creates a fresh binding each iteration, so each closure captures its own
copy.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)) // 0 1 2
With var, by the time the timers fire the loop has long finished and i is
3. With let, the language re-binds j per iteration (and even copies the
value forward for the increment), giving each callback 0, 1, 2. The
pre-let fix was an IIFE to manufacture a new scope each pass.
Yes. const freezes the binding, not the value. The variable must keep
pointing at the same array, but the array's contents are fully mutable.
const arr = [1, 2, 3]
arr.push(4) // [1, 2, 3, 4]
arr[0] = 99 // [99, 2, 3, 4]
arr.length = 0 // [] — still the same array
arr = [] // TypeError: Assignment to constant variable
If you want the contents to be immutable too, that's a separate concern —
use Object.freeze(arr) for a shallow freeze (and note freeze is shallow, so
nested objects can still change).
Lexical (static) scope means a variable's accessibility is determined by where it's written in the source code — the nesting of functions/blocks — not by where or how a function is called. The structure is fixed at author time.
const outer = 1
function f() {
const inner = 2
function g() { return outer + inner } // sees both by lexical nesting
return g()
}
f() // 3
Because scope is determined by position, you can tell what any function can access just by reading the code. JavaScript uses lexical scope (unlike languages with dynamic scope).
When you reference a variable, the engine looks in the current scope, then its
enclosing scope, and so on outward until the global scope — this series of
linked scopes is the scope chain. The first match wins; if none is found, it's a
ReferenceError.
const a = 'global'
function outer() {
const b = 'outer'
function inner() {
const c = 'inner'
return `${a} ${b} ${c}` // resolves c->inner, b->outer, a->global
}
return inner()
}
Lookups go inward-to-outward only — an outer scope can't see inner variables. This chain is also what closures preserve.
Shadowing is declaring a variable in an inner scope with the same name as one in an outer scope. The inner variable "shadows" the outer one within that scope; the outer remains unchanged outside.
const x = 1
function f() {
const x = 2 // shadows the outer x
console.log(x) // 2
}
f()
console.log(x) // 1 (outer untouched)
Shadowing is legal and sometimes useful, but accidental shadowing causes bugs.
Note you can't shadow with var/let in a way that accesses the outer value
mid-declaration (TDZ), and linters can warn on shadowing.
In strict mode (the default in modules/classes), a function declaration inside a block is block-scoped — visible only within that block. In sloppy mode the behavior is inconsistent across engines, which is a common gotcha.
'use strict'
if (true) {
function foo() { return 1 }
}
typeof foo // 'undefined' in strict mode — block-scoped
Best practice: don't declare functions inside blocks. Use a function
expression assigned to a let/const if you need a conditionally-defined
function, for predictable scoping.
A switch has one shared block scope across all its cases. Declaring
let/const in one case puts it in the TDZ for the entire switch, so another case
can hit a ReferenceError or a redeclaration SyntaxError.
switch (x) {
case 1:
let y = 'a' // y is scoped to the whole switch
break
case 2:
let y = 'b' // SyntaxError: 'y' already declared
break
}
Fix by wrapping each case body in its own block { ... }, giving each case
a separate scope.
The variable is hoisted, but the function value is not — only the
declaration is. So calling a function expression before its assignment fails
(undefined/TDZ), unlike a function declaration which is fully hoisted.
declared() // works — declaration hoisted
function declared() {}
expressed() // TypeError: expressed is not a function (var) / ReferenceError (let)
var expressed = function () {}
So function declarations are callable above their line; function expressions (including arrows) follow normal variable hoisting rules and aren't.
Class declarations are hoisted but, like let/const, remain in the
temporal dead zone — so you can't use a class before its declaration.
Unlike function declarations, they aren't callable early.
new Foo() // ReferenceError: Cannot access 'Foo' before initialization
class Foo {}
bar() // function declarations work
function bar() {}
So "classes are hoisted" is technically true, but the TDZ means it behaves like not-hoisted in practice. Always declare classes before using them.
A named function expression gives the function a name that's only visible inside its own body (not the outer scope). It's useful for self-reference (recursion) and clearer stack traces.
const factorial = function fact(n) {
return n <= 1 ? 1 : n * fact(n - 1) // `fact` usable inside
}
factorial(5) // 120
fact // ReferenceError — not visible outside
The inner name is safer than relying on the outer variable (which could be reassigned) and shows up in debuggers instead of "anonymous."
An inner function can read variables from all of its enclosing scopes, resolved via the scope chain. Each function adds a scope; lookups proceed outward until a match or the global scope.
function a() {
let x = 1
function b() {
let y = 2
function c() { return x + y } // reaches up two levels
return c()
}
return b()
}
a() // 3
Inner scopes see outer variables, never the reverse. This nested visibility is the foundation of closures.
Assigning to a variable without declaring it (x = 5) in sloppy mode creates
a property on the global object instead of erroring — a common source of bugs
and leaks. Strict mode throws a ReferenceError instead.
function f() {
count = 1 // no var/let/const -> implicit global
}
f()
console.log(count) // 1 (leaked to global!)
// strict mode:
'use strict'
function g() { total = 1 } // ReferenceError
Always declare variables, and use strict mode (automatic in modules) so this mistake fails loudly.
Strict mode tightens several variable rules: assigning to an undeclared variable
throws (no accidental globals), you can't delete a variable, duplicate
parameter names are errors, and this is undefined instead of the global object
in plain calls.
'use strict'
undeclared = 5 // ReferenceError
function dup(a, a) {} // SyntaxError
ES modules and class bodies are always strict. Strict mode surfaces bugs at author/parse time that would otherwise fail silently — a big reason modern code is strict by default.
Because const can be assigned only once, you must give it that value at
declaration — there's no later opportunity to assign. Declaring without a value is
a SyntaxError.
const x = 5 //
const y // SyntaxError: Missing initializer in const declaration
let and var can be declared without a value (defaulting to undefined), but
const's "assign once" contract requires the value up front. This also makes
const ineligible for the classic split declare-then-assign pattern.
During hoisting, function declarations take precedence over var
declarations with the same name. The var declaration is ignored (its
assignment, if any, still runs in place and can overwrite the function later).
console.log(typeof foo) // 'function' — function decl wins at hoist time
var foo = 'bar'
function foo() {}
console.log(typeof foo) // 'string' — the assignment ran
So at the top the name is the function; once execution reaches foo = 'bar', it
becomes the string. These clashes are confusing — avoid reusing names.
Each ES module has its own top-level scope — variables declared at the top of
a module are not global; they're private to that module unless exported.
Even top-level var doesn't attach to the global object.
// module.js
const secret = 42 // module-scoped, not global
export const api = {} // shared only via export
// window.secret -> undefined
This is a major improvement over classic scripts, where top-level var polluted
the global namespace. Module scope plus strict mode is why modern code avoids many
legacy footguns.
In a for loop, let creates a fresh binding per iteration and copies the
value forward — so closures created in the loop each capture their own copy. var
shares one binding across all iterations.
const fns = []
for (let i = 0; i < 3; i++) fns.push(() => i)
fns.map(f => f()) // [0, 1, 2]
const v = []
for (var j = 0; j < 3; j++) v.push(() => j)
v.map(f => f()) // [3, 3, 3]
This per-iteration binding is a deliberate let feature that fixes the
long-standing closure-in-loop bug.
No. typeof is safe for undeclared identifiers, but a let/const variable
in its TDZ is declared-but-uninitialized, so even typeof throws a
ReferenceError.
typeof undeclaredVar // 'undefined' (safe)
typeof x // ReferenceError (x is in the TDZ)
let x = 1
So the usual "use typeof to safely check existence" trick fails for block-scoped
variables before their declaration line. The TDZ deliberately makes
use-before-declaration an error.
A bare block is a standalone { ... } (not attached to if/for/etc.). With
let/const it creates a new block scope, so variables inside are invisible
outside — a lightweight way to limit scope. var ignores it (function-scoped).
{
let secret = 42
var leaked = 1
}
console.log(leaked) // 1 (var ignores the block)
console.log(secret) // ReferenceError (let is block-scoped)
Bare blocks can also resolve switch/temporary-variable naming conflicts by
isolating declarations.
Yes. A nested block can declare a new let/const with the same name, creating a
separate binding that shadows the outer one within that block. This is
different from redeclaration in the same scope (which errors).
let x = 1
{
let x = 2 // shadows — different (nested) scope
console.log(x) // 2
}
console.log(x) // 1
let y = 1
let y = 2 // SyntaxError — same scope
Shadowing across scopes is allowed; redeclaring in the same scope is not.
- Lexical scoping (what JS uses): a function's free variables resolve based on where it's defined in the source.
- Dynamic scoping (some other languages): free variables resolve based on the call stack at runtime — who called the function.
let x = 'global'
function inner() { return x }
function outer() { let x = 'outer'; return inner() }
outer() // 'global' — lexical: inner sees where it was DEFINED
// (a dynamically-scoped language would return 'outer')
JS's only "dynamic" feature is this, which is bound by the call site — but
ordinary variable lookup is always lexical.
JavaScript runs in two phases per scope: a creation (compilation) phase that
registers all declarations (allocating var as undefined, let/const as
uninitialized in the TDZ, functions fully), and an execution phase that runs
the code and performs assignments in order.
// creation phase registers: a (undefined), greet (function)
console.log(a) // undefined
var a = 1 // execution: a becomes 1
greet() // already callable
function greet() {}
So "hoisting" isn't physical code movement — it's declarations being processed before any line executes. Only assignments happen in place.
var is function-scoped, so a var declared inside an if/for/block is visible
throughout the entire enclosing function — leaking beyond where it's relevant,
which causes accidental reuse and bugs.
function f() {
for (var i = 0; i < 3; i++) {}
console.log(i) // 3 — i leaked out of the loop
if (true) { var temp = 'x' }
console.log(temp) // 'x' — leaked out of the if
}
let/const confine variables to their block, keeping scope tight. This leakage
is a primary reason to avoid var.
Object.freeze is shallow — it freezes the top-level properties but nested
objects remain mutable. For deep immutability you must recursively freeze.
const obj = Object.freeze({ a: 1, nested: { b: 2 } })
obj.a = 9 // ignored (or throws in strict mode)
obj.nested.b = 99 // still mutable! freeze is shallow
function deepFreeze(o) {
Object.values(o).forEach(v => v && typeof v === 'object' && deepFreeze(v))
return Object.freeze(o)
}
Check frozen status with Object.isFrozen. For shared constants, deep-freezing
prevents accidental mutation.
An IIFE runs immediately and creates a private function scope, so variables inside never touch the enclosing/global scope. Before block scoping and modules, it was the main tool to avoid polluting globals and to create private state.
(function () {
var temp = 'private' // not global
// setup work...
})()
typeof temp // 'undefined' — isolated
Today, block scope ({ let ... }) and ES modules cover most of these needs, but
IIFEs still appear in bundled code and for one-off encapsulation.
Default to const — it signals the binding won't be reassigned, preventing
accidental reassignment and making code easier to reason about. Use let only
when you genuinely need to reassign (counters, accumulators, reassigned in
branches). Avoid var.
const MAX = 100 // never reassigned
const user = { id: 1 } // const, but you can still mutate the object
let total = 0 // reassigned in a loop
for (const item of items) total += item.price
"const by default, let when needed" is the widely-adopted convention. Remember
const blocks reassignment, not mutation.
Globals are accessible and mutable from anywhere, so they create hidden coupling, name collisions between scripts/libraries, hard-to-trace bugs, and problems in concurrent/testing contexts. Any code can change them unexpectedly.
// global state anyone can clobber
var config = {}
// encapsulate in a module or closure
export const config = createConfig()
Minimize globals by using modules (module scope), closures, and passing dependencies explicitly. When a true global is needed, namespace it under a single object to limit collisions.
Practice tests are coming soon
Get notified when interactive mock interviews and quizzes launch.