JavaScriptIntermediate

JavaScript Scope and Hoisting: var, let, const and the TDZ

Understand global, function, block and lexical scope, how hoisting works for var, let, const and functions, and what the temporal dead zone means.

All JavaScript lessons

What you will learn

Why does one line of code see a variable while another says x is not defined? Why can you call some functions before you write them, but not others? The answers are scope and hoisting. They explain most “why is this undefined?” and “why does this crash?” moments for beginners. In this lesson you will learn the four kinds of scope (global, function, block and lexical), what hoisting does to var, let, const and functions, and what the temporal dead zone (TDZ) is.

What is scope?

Scope is the area of your code where a variable can be seen and used.

Think of a school building:

  • The school notice board in the main hall can be read by everyone. (global)
  • A classroom whiteboard can be read only by people inside that classroom. (function or block)
  • A teacher inside a classroom can also read the notice board in the hall, but a person in the hall cannot read the whiteboard inside the classroom.

Scope protects your variables: names do not clash, and values do not change by accident.

Global scope

A variable created outside any function or block is global. It can be used everywhere in the file.

const siteName = "MyCodeNest";

function showSite() {
  console.log(siteName);   // can see the global variable
}

showSite();                // "MyCodeNest"
console.log(siteName);     // "MyCodeNest"

Globals are handy for fixed values, but too many of them cause trouble:

  • Two parts of the program may use the same name by accident.
  • Any code can change them, so a bug could come from anywhere.

var at the top level becomes a property of window

In a normal browser script, a global var (and a global function) is attached to the window object, while let and const are not:

var oldGlobal = "I am on window";
let newGlobal = "I am not on window";

console.log(typeof window !== "undefined" && window.oldGlobal);   // "I am on window" in a browser
console.log(typeof window !== "undefined" && window.newGlobal);   // undefined in a browser

This is one more reason to prefer let and const.

Accidental globals

If you assign to a name that was never declared, JavaScript (in normal mode) quietly creates a global variable:

function calculate() {
  total = 100;   // forgot let or const!
}

calculate();
console.log(total);   // 100 (leaked into the global scope)

In strict mode ("use strict", and also inside modules), this is an error: ReferenceError: total is not defined. Always declare variables with let or const.

Function scope

A variable created inside a function exists only inside that function. It is local.

function makeGreeting() {
  const message = "Hello from inside";
  console.log(message);   // works
}

makeGreeting();
console.log(message);     // ReferenceError: message is not defined
  • Parameters are local variables too.
  • Every call of a function creates a fresh set of local variables. Nothing is remembered between calls.
function count() {
  let total = 0;
  total++;
  return total;
}

console.log(count());   // 1
console.log(count());   // 1  (a new "total" each time)

var is function-scoped

Variables created with var belong to the whole function, no matter which { } they were written in. That is the old behaviour, and the root of many bugs, as you will see next.

Block scope

A block is any code between { }: the body of an if, a for loop, a while loop, or just a plain pair of braces.

let and const are block scoped: they exist only inside the block where they were created. var ignores blocks.

if (true) {
  var a = "I am var";
  let b = "I am let";
  const c = "I am const";
}

console.log(a);   // "I am var"  (leaked out of the block!)
console.log(b);   // ReferenceError: b is not defined
console.log(c);   // ReferenceError: c is not defined

Loops

for (var i = 0; i < 3; i++) {
  // ...
}
console.log(i);   // 3  (the var survived the loop)

for (let j = 0; j < 3; j++) {
  // ...
}
console.log(typeof j);   // "undefined"  (j existed only inside the loop)

Block scope keeps temporary variables, such as loop counters, from leaking and being reused by mistake.

Scope summary

Declared with Scope Redeclare in same scope? Attached to window (global)?
var Function Yes Yes
let Block No No
const Block No No

Lexical scope and the scope chain

Lexical scope means a function’s scope is decided by where it is written in the code, not where it is called.

A function can use variables from its own scope and from every scope around it. When JavaScript looks for a variable, it follows the scope chain:

  1. Look in the current scope.
  2. If not found, look in the outer scope.
  3. Keep going outward until the global scope.
  4. If it is still not found, throw a ReferenceError.
const level1 = "global";

function outer() {
  const level2 = "outer";

  function inner() {
    const level3 = "inner";
    console.log(level1, level2, level3);   // "global outer inner"
  }

  inner();
}

outer();

inner can see all three variables, because it is written inside outer, which is written inside the global scope. The chain only goes outward. The global code cannot see level2 or level3.

Written position wins over call position

const name = "Global";

function show() {
  console.log(name);
}

function test() {
  const name = "Local";
  show();   // called from inside test...
}

test();     // ...but prints "Global"

show was written in the global scope, so it uses the global name. It does not matter that it was called from test. This is the heart of lexical scope, and it makes closures (a later lesson) possible.

Shadowing

When an inner scope declares a variable with the same name as an outer one, the inner one hides (shadows) the outer one, but only inside that scope.

const color = "blue";

function paint() {
  const color = "red";    // shadows the outer color
  console.log(color);     // "red"
}

paint();
console.log(color);       // "blue" (the outer one is untouched)

It is legal, but it can confuse readers. Prefer different names, like selectedColor.

Hoisting

Before JavaScript runs your code, it makes a first pass and registers all the declarations in each scope. As a result, it seems like declarations are “moved to the top” of their scope. This is hoisting.

Hoisting works differently for functions, var, and let/const.

Function declarations are fully hoisted

You can call them before the line where they are written:

console.log(add(2, 3));   // 5  (works!)

function add(a, b) {
  return a + b;
}

This lets you put your main code at the top and the helper functions below, which many people find easy to read.

var is hoisted, but starts as undefined

The declaration is hoisted, but the value is not:

console.log(score);   // undefined  (no error, which hides bugs)
var score = 10;
console.log(score);   // 10

JavaScript treats the code as if you had written:

var score;            // moved to the top
console.log(score);   // undefined
score = 10;
console.log(score);   // 10

let and const are hoisted too, but cannot be used early

console.log(age);   // ReferenceError: Cannot access 'age' before initialization
let age = 20;

They are registered, but kept uninitialised until the line where you declare them. That “forbidden” period is the temporal dead zone.

Function expressions and arrow functions are not hoisted like declarations

The variable follows the rules of var, let or const:

// With const (or let): ReferenceError
console.log(typeof greet);   // throws, because greet is in the TDZ
const greet = () => "Hello";
// With var: the variable exists but is undefined, so calling it fails
sayHi();   // TypeError: sayHi is not a function
var sayHi = function () {
  return "Hi";
};

Hoisting at a glance

What Can you use it before its line? What you get
function name() {} Yes Works normally
var x = 1 Yes, but undefined
let x = 1 / const x = 1 No ReferenceError (TDZ)
const f = () => {} / const f = function () {} No ReferenceError (TDZ)
var f = function () {} Variable yes, function no TypeError: f is not a function
class A {} No ReferenceError (TDZ)

The temporal dead zone (TDZ)

The temporal dead zone is the time between the start of a scope and the line where a let or const is declared. If you touch the variable during that time, you get a ReferenceError.

{
  // TDZ for "city" starts here
  // console.log(city);   // ReferenceError: Cannot access 'city' before initialization
  let city = "Delhi";     // TDZ ends here
  console.log(city);      // "Delhi"
}

It is called temporal because it depends on time (the order in which the lines run), not on position alone.

Proof that let is hoisted

If let were not hoisted, the next code would print 10, the outer value. It crashes instead, because the inner let x was registered at the top of its block and is still in the TDZ:

let x = 10;

{
  console.log(x);   // ReferenceError: Cannot access 'x' before initialization
  let x = 20;
}

Why does the TDZ exist?

The TDZ helps you. Instead of silently giving undefined like var, JavaScript stops you immediately and tells you that you used a variable too early. The bug is easy to find.

Even typeof, which is usually safe, is not safe inside the TDZ:

console.log(typeof neverDeclared);   // "undefined" (safe for names that do not exist at all)

{
  // console.log(typeof inTdz);      // ReferenceError!
  let inTdz = 1;
}

Reading the error messages

Message What it usually means
ReferenceError: x is not defined There is no variable x in any scope you can see (a typo, or it lives in an inner scope)
ReferenceError: Cannot access 'x' before initialization You used a let / const before its line (TDZ)
x is undefined (no error) A var used before its value was set, a missing argument, or a missing property
TypeError: x is not a function x exists but holds something else, such as undefined from a hoisted var
TypeError: Assignment to constant variable You tried to reassign a const

Best practices

  • Use const by default, let when the value changes. Avoid var.
  • Declare variables at the top of their scope, and as close to where they are used as makes sense.
  • Keep scopes small. Small functions and blocks mean fewer places for bugs to hide.
  • Avoid globals. Pass values into functions as parameters instead.
  • Always declare variables. Use "use strict" or modules to catch accidental globals.
  • Use different names, rather than shadowing.
  • Define function expressions and arrow functions before you call them.

Real-life use cases

1. A var loop counter that leaks

function findFirstNegative(numbers) {
  for (var i = 0; i < numbers.length; i++) {
    if (numbers[i] < 0) {
      break;
    }
  }
  return i;   // "works" only because var leaks out of the loop
}

console.log(findFirstNegative([4, 8, -2, 9]));   // 2

It works by accident. With let, the i would be gone after the loop, and you would see the mistake at once. Write the intention clearly instead:

function findFirstNegativeIndex(numbers) {
  for (let i = 0; i < numbers.length; i++) {
    if (numbers[i] < 0) {
      return i;
    }
  }
  return -1;
}

console.log(findFirstNegativeIndex([4, 8, -2, 9]));   // 2

2. Main code on top, helpers below

Function declarations are hoisted, so you can read the program in order:

const total = calculateTotal([250, 99, 1499]);
console.log(formatPrice(total));   // "₹1848.00"

function calculateTotal(prices) {
  let sum = 0;
  for (const price of prices) {
    sum += price;
  }
  return sum;
}

function formatPrice(amount) {
  return "₹" + amount.toFixed(2);
}

3. Block scope in if / else

const score = 82;

if (score >= 40) {
  const message = "You passed!";
  console.log(message);
} else {
  const message = "Try again!";   // a different variable: no clash
  console.log(message);
}

Each block has its own message, with no conflict.

4. Shadowing bug

let total = 0;
const prices = [100, 200, 300];

for (const price of prices) {
  let total = price * 1.18;   // oops: a NEW total, which shadows the outer one
}

console.log(total);   // 0 (not what we wanted!)

The fix is to remove the inner let, so the loop updates the outer variable:

let total = 0;

for (const price of [100, 200, 300]) {
  total += price * 1.18;
}

console.log(total.toFixed(2));   // "708.00"

5. Temporary variables that stay private

function getAverage(numbers) {
  let sum = 0;
  for (const n of numbers) {
    sum += n;
  }
  return sum / numbers.length;
}

console.log(getAverage([70, 80, 90]));   // 80
// "sum" is gone after the function finishes, so it cannot clash with anything else

6. Separate variables in switch cases

Every case shares one block, so repeated let names clash. Wrap each case in its own braces:

const action = "square";
const number = 4;

switch (action) {
  case "square": {
    const result = number * number;
    console.log(result);   // 16
    break;
  }
  case "double": {
    const result = number * 2;   // OK: separate block
    console.log(result);
    break;
  }
}

Common mistakes

  • Using a variable outside the block or function where it lives. ReferenceError: x is not defined.
  • Forgetting let or const. total = 5 creates an accidental global.
  • Using var and expecting block scope. var leaks out of if blocks and loops.
  • Using let or const before the declaration line. The TDZ throws a ReferenceError.
  • Calling a function expression or arrow function before it is created. Only function declarations are hoisted.
  • Reusing a variable name in an inner scope without noticing. It shadows the outer variable.
  • Believing that hoisting physically moves your code. It does not. It is how JavaScript registers declarations before running.
  • Relying on hoisting to hide messy ordering. Write code in the order it should be read.
  • Thinking const makes a variable global-safe. A global const can still be read from everywhere.

Practice

  1. Predict the output, then run it:

    var a = 1;
    function test() {
      console.log(a);
      var a = 2;
    }
    test();

    Why is it undefined and not 1?

  2. Predict the output:

    console.log(hello());
    function hello() {
      return "Hello!";
    }
  3. Fix this code so it does not crash, without changing the function’s body:

    console.log(double(4));
    const double = (n) => n * 2;
  4. What does each console.log print?

    const x = "global";
    function outer() {
      const x = "outer";
      function inner() {
        console.log(x);
      }
      inner();
    }
    outer();
    console.log(x);
  5. Explain why this code throws an error, even though there is a let x further up:

    let x = 5;
    function show() {
      console.log(x);
      let x = 10;
    }
    show();
  6. Write a loop with var and another with let. After each loop, print the counter and describe the difference.

  7. Rewrite this to avoid an accidental global:

    function addTax(price) {
      result = price * 1.18;
      return result;
    }
  8. Put the main logic of a small program at the top and its helper functions below. Explain why it works.

  9. Find and fix the shadowing bug in a loop that tries to add up numbers with let sum = 0 declared inside the loop.

  10. Challenge: without running it, write the output of the program below, then check yourself:

    let value = "global";
    
    function first() {
      console.log(value);
    }
    
    function second() {
      let value = "second";
      first();
      console.log(value);
    }
    
    second();

Recap

  • Scope decides where a variable can be used: global (everywhere), function (inside the function) and block (inside { }, for let and const).
  • Lexical scope: a function uses the variables from where it was written, following the scope chain outward until the global scope. Inner code can see outer variables, but not the other way round.
  • Shadowing hides an outer variable with an inner one of the same name. Avoid it.
  • var is function-scoped, leaks out of blocks and can be redeclared. let and const are block-scoped. Use const by default.
  • Hoisting: declarations are registered before code runs. Function declarations work fully before their line. var gives undefined. let, const and classes cannot be used early.
  • The temporal dead zone is the time between the start of a scope and the declaration of a let or const. Touching the variable then throws a ReferenceError.
  • Always declare your variables, keep scopes small, and avoid globals.
  • Next you will learn callbacks and higher-order functions: passing functions to other functions.