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 Explained

They 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:

  • var declarations are hoisted and initialized to undefined, so reading one before its assignment gives undefined (no error).
  • Function declarations are hoisted entirely — you can call them above where they're written.
  • let/const are 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.