let, const and block scope

Beginner30 min

Declare variables the modern way with const and let, understand block scope and the temporal dead zone, and know why var is legacy.

What you will learn
  • Choose between const and let for each variable
  • Explain block scope and shadowing
  • Explain the temporal dead zone and what var hoisting does differently
  • Say what const does and does not protect
On this page

A variable is a name for a value. What a variable is, and how assignment differs from comparison, is covered in the fundamentals lesson on variables and assignment. This lesson covers the three ways JavaScript lets you declare one and the scope rules that come with each.

const and let

Modern JavaScript has two declarations you should use, const and let. Both arrived in ES2015.

  • const creates a name that cannot be assigned again.
  • let creates a name that can.
let_const_basics.mjs
let score = 10;
score = score + 5;
console.log(score);
const limit = 100;
console.log(limit);
Output
15
100

score changes, so it is a let. limit never changes, so it is a const. Assigning to a const stops the program:

const limit = 100;
limit = 200;
Output
TypeError: Assignment to constant variable.

Lesson 15 shows how to catch an error like this one instead of letting it stop the program.

The next example shows that const does not make a value unchangeable. It fixes the binding between the name and the value. If the value is an array (a list) or an object (a record), you can still change what is inside it. push adds an item to the end of a list, and config.retries = 5 replaces one part of a record.

const_contents.mjs
const scores = [10, 20];
scores.push(30);
console.log(scores);
const config = { retries: 3 };
config.retries = 5;
console.log(config);
Output
[ 10, 20, 30 ]
{ retries: 5 }

The lesson on copies and references explains why this works. For now, remember what const guards: the name, not the contents.

The working rule is to start with const and change to let only when you find the variable must be reassigned. Readers then know that every const keeps its meaning for the whole scope, and every let is a warning that the value moves.

Updating a variable

A let variable is often updated from its own value. JavaScript has short forms for that. They are called compound assignment operators.

Short form Same as
x += 3 x = x + 3
x -= 3 x = x - 3
x *= 3 x = x * 3
x /= 3 x = x / 3
x++ and ++x x = x + 1
x-- and --x x = x - 1

x++ and ++x both add one, but they differ in the value they produce. The postfix form x++ gives back the old value and then adds one. The prefix form ++x adds one and gives back the new value. The same holds for --.

update_operators.mjs
let count = 5;
count += 3;
count *= 2;
console.log(count);
let n = 1;
console.log(n++, n);
console.log(++n, n);
Output
16
1 2
3 3

count goes from 5 to 8 and then to 16. In the last two lines, n++ prints the old value 1 and n becomes 2. Then ++n makes n 3 and prints 3. When you use ++ on a line of its own, the two forms do the same thing. Avoid mixing them into larger expressions, because they are hard to read.

Block scope

A block is the code between { and }. A bare pair of braces is a block, and so are the bodies of the if statements, loops and functions that the next lessons cover. Variables declared with let and const exist only inside the block where they are declared.

block_scope.mjs
const outer = "outside";
{
let inner = "inside";
const alsoInner = 42;
var leaky = "var ignores blocks";
console.log(outer, inner, alsoInner);
}
console.log(leaky);
console.log(typeof inner);
Output
outside inside 42
var ignores blocks
undefined

inner and alsoInner are visible inside the braces and nowhere else. typeof inner outside the block gives "undefined" because no such name exists there. (Using typeof on a name that was never declared does not throw. Names in the temporal dead zone, described below, are the exception.)

The var line behaves differently, and the output shows it: leaky is still available after the block. We come back to that below.

A block can also shadow a name from outside it:

shadowing.mjs
let name = "outer name";
{
let name = "inner name";
console.log(name);
}
console.log(name);
Output
inner name
outer name

Declaring name again inside the braces creates a new, separate variable that hides the outer one until the block ends. Shadowing is legal, and it is also a common source of confusion. Prefer a different name when the two variables could be mixed up.

The scope rules in general, including lifetime, are in the fundamentals lesson on scope and lifetime. The scope of functions gets its own lesson in the next module.

The temporal dead zone

You cannot use a let or const variable before its declaration line runs. The span between the start of the scope and that line is the temporal dead zone, and touching the variable there throws a ReferenceError.

console.log(later);
let later = "now it exists";
Output
ReferenceError: Cannot access 'later' before initialization

The message is the one to remember: “Cannot access ‘later’ before initialization”. It tells you the name exists in this scope but has no value yet. That is a bug you want to find early, and the dead zone turns it into an immediate error instead of a quiet undefined. Even typeof later throws in this zone, so typeof is only safe for names that were never declared at all.

var and hoisting

Before ES2015 the only declaration was var. It still works, and you will meet it in older code. It has differences that cause bugs.

  1. var is scoped to the whole function, not the block. A var inside braces is visible after them, as leaky showed above.
  2. var is hoisted and starts as undefined. The declaration is treated as if it were at the top of its scope, so reading it early gives undefined instead of an error.
  3. var allows the same name twice in one scope. A second var greeting does not complain, which hides typos.
var_hoisting.mjs
console.log(count);
var count = 5;
console.log(count);
var greeting = "hello";
var greeting = "hello again";
console.log(greeting);
Output
undefined
5
hello again

count is undefined on the first line, not an error. The second var greeting silently replaces the first. With let or const, that second declaration would be a SyntaxError.

var has one more trap, in loops: a var loop counter is shared by every pass, while a let counter is new on each pass. The loops lesson and the lesson on closures show where that matters.

Use let and const in everything you write. Read var when you meet it, and replace it with const or let when you edit that code.

Common pitfalls

  • Treating const as “frozen”. const stops reassignment only. A list or record declared with const can still change inside.
  • Declaring with let out of habit. If a variable is never reassigned, make it const.
  • Reading a variable above its declaration. That is the temporal dead zone. Move the declaration up.
  • Declaring the same name twice in one scope. let x; let x; is a SyntaxError. var x; var x; is allowed and hides mistakes.
  • Using a name after its block. With let and const it is out of scope, which is usually what you want.

Exercises

ExerciseFix the var bugs

This older program uses var throughout. Rewrite it with const and let so that: each name is declared once, visitor exists only inside its braces (print it there), and the program prints Ada and then Hi 15.

var_bugs.mjs
var greeting = "Hello";
var greeting = "Hi";
{
var visitor = "Ada";
}
var total = 10;
total = total + 5;
console.log(greeting, visitor, total);
Output
Hi Ada 15

Input: none. The program above prints Hi Ada 15. Expected output after your fix: Ada, then Hi 15.

Show solution
var_fixed.mjs
const greeting = "Hi";
{
const visitor = "Ada";
console.log(visitor);
}
let total = 10;
total += 5;
console.log(greeting, total);
Output
Ada
Hi 15

greeting is declared once and never reassigned, so it is a const. visitor is a const inside the braces and is printed there. total changes, so it stays a let, and total += 5 is the short form of the update.

ExerciseShadowing puzzle

Predict the four lines this program prints, then run it.

const label = "outer";
{
const label = "inner";
console.log(label);
{
const label = "innermost";
console.log(label);
}
console.log(label);
}
console.log(label);

Input: none. Expected output: inner, innermost, inner, outer.

Show solution
shadow_puzzle.mjs
const label = "outer";
{
const label = "inner";
console.log(label);
{
const label = "innermost";
console.log(label);
}
console.log(label);
}
console.log(label);
Output
inner
innermost
inner
outer

Each pair of braces gets its own label. When a block ends, its label disappears and the next one out becomes visible again.

ExerciseSwap two variables

Declare let a = 1; and let b = 2;. Swap their values with the help of a third variable, then print both on one line.

Input: none. Expected output: 2 1.

Show solution
swap_values.mjs
let a = 1;
let b = 2;
const temp = a;
a = b;
b = temp;
console.log(a, b);
Output
2 1

a and b change, so they are let. temp holds the old a and is never reassigned, so it is a const. Without temp, the second assignment would have lost the first value.

ExerciseCount with ++

Predict the three lines this program prints, then run it.

let n = 5;
console.log(n++);
console.log(++n);
console.log(n);

Input: none. Expected output: 5, 7, 7.

Show solution
count_plusplus.mjs
let n = 5;
console.log(n++);
console.log(++n);
console.log(n);
Output
5
7
7

n++ gives the old value, 5, and then n becomes 6. ++n makes n 7 first and gives back 7. The last line shows the final value, 7.

Summary

  • Use const by default and let when a variable must be reassigned. +=, -=, ++ and -- update a let variable in place.
  • let and const are block-scoped. A name shadowed in an inner block hides the outer one until the block ends.
  • Using a let or const before its declaration is a ReferenceError (the temporal dead zone).
  • var is function-scoped, hoisted as undefined and may be declared twice. Treat it as legacy.
  • const fixes the binding, not the contents of the value.

Next: choosing between code paths with if, switch and the conditional operator, in the conditionals lesson.