Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

JavaScript (ES2026)  Module 2 – Values, types and operators

Variables with let and const

How let and const declare variables in JavaScript: block scope, the temporal dead zone, why const does not freeze an object, and how to name variables well.

  • Beginner
  • 15 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Declare variables with const by default and let when reassignment is needed
  • Explain block scope and why const does not make objects immutable
  • Name variables so code reads clearly

Before you start

What you’ll learn · Before you start

What you will learn

  • Declare variables with const by default and let when reassignment is needed
  • Explain block scope and why const does not make objects immutable
  • Name variables so code reads clearly

Before you start

On this page

A variable is a name that refers to a value. JavaScript gives you two ways to declare one: const total = 0 creates a name that can never be pointed at another value, and let count = 0 creates one that can be reassigned later. Both belong to the block, the pair of braces, they are declared in, and neither can be used before the line that declares it has run. Start with const for every name, and switch to let only when the code really has to give the name a new value, such as a counter or a running total. Old code also uses var, which follows different rules; a later lesson on hoisting explains it, and this one shows why modern code avoids it.

Declaring a variable

A declaration has three parts: the keyword, the name and, usually, an initial value after =. The difference between the two keywords is what happens afterwards.

Changing an array declared with const, then trying to reassign it JavaScript · counter-bindings.mjs
// const fixes the binding: the name always refers to the same value. That value can still change inside.
const coaches = ['S1', 'S2'];
coaches.push('B1'); // changes the array that coaches refers to: allowed
console.log(coaches);

try {
  // eslint-disable-next-line no-const-assign -- the TypeError is the point (a linter reports it before the code runs)
  coaches = ['A1']; // points the name at a different array: not allowed
} catch (error) {
  console.log(error.name, '- a const binding cannot be reassigned');
}
console.log(coaches); // still the same array, with all three coaches

let delayedTrains = 0; // let: this binding is meant to change
delayedTrains = delayedTrains + 1;
delayedTrains += 2;
console.log('delayed trains:', delayedTrains);

Output

[ 'S1', 'S2', 'B1' ]
TypeError - a const binding cannot be reassigned
[ 'S1', 'S2', 'B1' ]
delayed trains: 3

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node counter-bindings.mjs

coaches.push('B1') works, even though coaches was declared with const, and the third line of output shows the array still holding all three coaches after the failed assignment. The reason is the most important idea of this lesson: const protects the binding, the link between the name and its value, and not the value itself. Changing the contents of the array leaves the name pointing at the same array, so const has nothing to object to. coaches = ['A1'] would point the name at a different array, and that is what a const binding refuses.

The const name coaches points at one array. Changing the array is allowed; pointing the name at another array is a TypeError.const fixes the binding, not the valuecoachesthe name (binding)['S1', 'S2', 'B1']push('B1') changed it: allowedrefers to['A1']coaches = ['A1']would move the name: TypeError

What const protects: the binding, not the value

Text description of the diagram

The diagram has a name on the left and two arrays on the right.

  • The name `coaches`, declared with `const`, is a binding: it refers to a value.
  • A solid arrow, labelled "refers to", runs from the name to the array `['S1', 'S2', 'B1']`. The note under the array says that `push('B1')` changed it, and that this is allowed: the array is still the same array, it just holds one more item.
  • A dashed arrow runs from the name to a second array, `['A1']`, and is crossed out. The note says that `coaches = ['A1']` would move the name to another value, which a `const` binding refuses with a `TypeError`.

So `const` stops the name from being pointed at a different value; it does not stop the value itself from changing.

A let binding can be reassigned as often as you like, with = or with the compound operators such as +=, which is what delayedTrains does.

What the error looks like when nothing catches it

The example above catches the TypeError so that it can carry on. Without a try block, the program stops at the assignment, and Node.js prints the file, the line, a caret under the spot and the error:

Reassigning a const, without a try block JavaScript · reassign-const.mjs
const seatsLeft = 12;
console.log('seats left:', seatsLeft);
// eslint-disable-next-line no-const-assign -- the TypeError is the point of this example
seatsLeft = seatsLeft - 1; // the program stops here
console.log('this line never runs');

Output (exit status 1)

seats left: 12

Printed as an error (standard error)

reassign-const.mjs:4
seatsLeft = seatsLeft - 1; // the program stops here
          ^

TypeError: Assignment to constant variable.
    at reassign-const.mjs:4:11
    at ModuleJob.run (node:internal/modules/esm/module_job:561:25)
    at async node:internal/modules/esm/loader:647:26
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)

Node.js v24.21.0

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node reassign-const.mjs

The first console.log ran, because the error happens only when the faulty line is reached. Two more rules follow from the same idea. A const must get its value in the declaration, so const limit; on its own is a SyntaxError. A let may leave it out: let reply; creates a variable whose value is undefined until something is assigned.

Making the value itself read-only

When a value really must not change, for example a table of settings that the rest of the program shares, freeze it: Object.freeze(settings) stops properties from being added, removed or changed. The freeze is shallow, so an object nested inside a frozen object can still be changed, and in a module, which always runs in strict mode, an attempt to change a frozen property throws a TypeError instead of being silently ignored.

const or let?

const, let and the older var
Criterion constletvar
Can be reassigned no: TypeErroryesyes
Needs a value in the declaration yesno: starts as undefinedno: starts as undefined
Scope the enclosing blockthe enclosing blockthe whole function, ignoring blocks
Used before its declaration ReferenceErrorReferenceErrorgives undefined
Declared twice in one scope SyntaxErrorSyntaxErrorallowed
A fresh copy for each pass of a for loop yes (for…of and for…in)yesno: one variable for the whole loop
When to choose The default for every name: most names never get a second valueCounters, running totals and values that are set in different branchesOnly when you maintain old code that already uses it

The rule of thumb is simple: write const first, and when the code needs to give the name a new value, change that one declaration to let. MDN’s style guide for JavaScript asks for the same thing. A reader who sees const knows at once that the name means the same thing on every later line, which is often the most useful fact about a variable. Most names in real programs never change: the result of a calculation, an element found on a page, an imported function. The ones that do, a loop counter or a total that grows, stand out as let.

Block scope

A block is a group of statements between braces: the body of an if, of a loop, of a function, or a pair of braces on its own. A variable declared with let or const exists only inside the block that declares it, from the point of view of the code around it. Inside a block you may even declare a name that already exists outside; the inner one then shadows the outer one until the block ends.

The same name in three nested scopes JavaScript · block-scope.mjs
const level = 'module';
{
  const level = 'outer block';
  {
    const level = 'inner block';
    console.log('inside the inner block:', level);
  }
  console.log('back in the outer block:', level);
}
console.log('at the top of the module:', level);

if (level === 'module') {
  const message = 'declared inside the if block';
  console.log(message);
}
// message does not exist out here, so typeof reports a name that was never declared.
console.log('typeof message after the block:', typeof message);

Output

inside the inner block: inner block
back in the outer block: outer block
at the top of the module: module
declared inside the if block
typeof message after the block: undefined

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node block-scope.mjs

Each level is a separate variable, and leaving a block brings the outer one back into view. message lives only inside the if block, so after the block the name does not exist at all, and typeof reports undefined, the answer it gives for a name that was never declared. Keeping a variable inside the smallest block that needs it means fewer places in the program can change it by accident.

let in a for loop

Blocks matter most in loops. A for loop with let gives every pass of the loop its own copy of the counter, copied from the previous pass before the counter moves on. A function created in the loop keeps the copy of its own pass:

Functions created in a loop, with let and with var JavaScript · loop-bindings.mjs
// Each function remembers the variable i, and reads it later, when it is called.
const withLet = [];
for (let i = 1; i <= 3; i++) {
  withLet.push(() => i); // let: every pass of the loop gets its own i
}
console.log('let:', withLet.map((read) => read()).join(' '));

const withVar = [];
for (var j = 1; j <= 3; j++) {
  withVar.push(() => j); // var: one j for the whole loop, 4 when the loop ends
}
console.log('var:', withVar.map((read) => read()).join(' '));

Output

let: 1 2 3
var: 4 4 4

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node loop-bindings.mjs

With var, the three functions share one variable, and by the time they are called the loop has finished and left it at 4. Before let existed, this was a classic source of bugs in event handlers and timers; the exercise below contains one such bug for you to fix.

Before the declaration: the temporal dead zone

A let or const variable exists from the start of its block, but it cannot be read or written until its declaration has run. The stretch of code in between is called the temporal dead zone, and any use of the name there throws a ReferenceError:

Reading a name too early, directly and through a function JavaScript · dead-zone.mjs
// typeof is safe for a name that was never declared ...
console.log('typeof neverDeclared:', typeof neverDeclared);

// ... but a let or const name exists from the start of its block, and reading it before its declaration throws.
try {
  console.log(typeof fare);
} catch (error) {
  console.log('typeof fare before its declaration:', error.name);
}
const fare = 120;
console.log('fare after its declaration:', fare);

function showTotal() {
  return total; // writing this is fine: total is only read when showTotal runs
}
try {
  showTotal();
} catch (error) {
  console.log('showTotal() before total is declared:', error.name);
}
const total = 450;
console.log('showTotal() after the declaration:', showTotal());

Output

typeof neverDeclared: undefined
typeof fare before its declaration: ReferenceError
fare after its declaration: 120
showTotal() before total is declared: ReferenceError
showTotal() after the declaration: 450

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node dead-zone.mjs

Two details are worth noticing. typeof normally never throws, and for a name that is not declared anywhere it returns 'undefined', which code uses to test for features that may be missing. For a name in its temporal dead zone it throws like any other access. And the zone is about time, not about position in the file: the function showTotal may mention total above its declaration, and calling it is fine once the declaration has run.

Mistakes the engine catches

Declaring every name has another benefit. A JavaScript module, and any code marked 'use strict', runs in strict mode, and in strict mode assigning to a name that was never declared is an error. Old, non-strict scripts quietly created a new global variable instead, so a typo in a name went unnoticed:

A typo in a variable's name JavaScript · typo-in-name.mjs
let discount = 0;
discount = 5; // this update works
try {
  // eslint-disable-next-line no-undef -- the typo is the point of this example
  dicsount = 10; // a typo: this name was never declared
} catch (error) {
  console.log(error.name, '- a module runs in strict mode, so a typo cannot create a new variable');
}
console.log('discount is still', discount);

Output

ReferenceError - a module runs in strict mode, so a typo cannot create a new variable
discount is still 5

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node typo-in-name.mjs

Declaring the same name twice in one scope is caught even earlier. The engine reads the whole module before it runs any of it, finds the second declaration and refuses to start, which is why not even the first line prints:

Declaring total twice JavaScript · declared-twice.mjs
/* eslint-disable no-redeclare, @typescript-eslint/no-unused-vars -- the SyntaxError is the point of this example */
console.log('This line never runs: the engine checks the whole module before running any of it.');
let total = 0;
let total = 1;

Output (exit status 1)

Printed as an error (standard error)

declared-twice.mjs:4
let total = 1;
    ^

SyntaxError: Identifier 'total' has already been declared
    at compileSourceTextModule (node:internal/modules/esm/utils:319:16)
    at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:89:18)
    at #translate (node:internal/modules/esm/loader:438:20)
    at afterLoad (node:internal/modules/esm/loader:506:29)
    at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:511:12)
    at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:564:36)
    at afterResolve (node:internal/modules/esm/loader:611:52)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:617:12)
    at node:internal/modules/esm/loader:636:32
    at TracingChannel.tracePromise (node:diagnostics_channel:362:14)

Node.js v24.21.0

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node declared-twice.mjs

Errors found this way, before any code runs, are called early errors. Missing initial values of const and names declared twice are two of them.

JavaScript Linter The Strict rule set flags every var and every let that could be a const; the recommended rules catch undeclared names and assignments to a const before you run anything.

Naming variables

The language’s rules for names are short. A name, or identifier, starts with a letter, $ or _, and continues with letters, digits, $ and _. Letters from any script count, and case matters, so total and Total are two different variables. Reserved words such as class, return or const cannot be names.

Valid names, by the rules and by convention JavaScript · names.mjs
const seatsLeft = 12; // camelCase: the usual style for variables
const MAX_SEATS = 72; // capitals: a fixed setting, known before the program runs
const $price = 499; // $ and _ may appear anywhere in a name
const _draft = true;
const नाम = 'Asha'; // letters of any script are allowed
const total = 1;
const Total = 2; // case matters: a second, different name
console.log(seatsLeft, MAX_SEATS, $price, _draft, नाम, total, Total);

Output

12 72 499 true Asha 1 2

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node names.mjs

Convention adds what the rules leave open:

  • camelCase for variables and functions: seatsLeft, ticketPrice, sendReminder. Classes use PascalCase: TrainSchedule.
  • Capitals with underscores for a fixed setting that is known before the program runs, such as MAX_SEATS = 72. Most const variables are not of this kind, so they keep ordinary camelCase names: const total = price * quantity is a value the program computed, not a setting.
  • Names that say what the value means. seatsLeft tells the next reader far more than s or data2. A name of a few words is fine; an abbreviation that only you understand is not.
  • Plain letters in shared code. Names such as नाम are valid, but a team whose members type in different scripts reads and searches code more easily when names use ASCII letters.

Exercise

Exercise · Easy · JavaScript

Fix two bugs that come from var

boarding.mjs holds two helpers of a station's display, and each has a bug that comes from declaring its variables with var. Fix both with let and const only: no var, and const for every name that is never reassigned.

  • boardingCalls(coaches) returns one function per coach. Calling a function returns that coach's call, so boardingCalls(['S1', 'S2'])[1]() is 'Coach S2 is now boarding'. Today every function names a coach called undefined.
  • delayNotes(trains) returns one line per train: '<name> is <delay> minutes late', or '<name> is on time' when its delay is 0. Today a train that is on time but comes after a late one gets the late train's line.

For example, delayNotes([{ name: 'Rajdhani', delay: 25 }, { name: 'Shatabdi', delay: 0 }]) should be ['Rajdhani is 25 minutes late', 'Shatabdi is on time']. The sample tests call both functions and also read their source text to check that var is gone.

Starter code · boarding.mjs

/**
 * Two helpers of a station's display. Both have a bug that comes from declaring variables with var: fix them with
 * const and let only (no var, and const for every name that is never reassigned).
 */

/** One function per coach: calling it returns that coach's boarding call. */
export function boardingCalls(coaches) {
  var calls = [];
  for (var i = 0; i < coaches.length; i++) {
    calls.push(() => `Coach ${coaches[i]} is now boarding`);
  }
  return calls;
}

/** One line per train: "<name> is <delay> minutes late", or "<name> is on time" when its delay is 0. */
export function delayNotes(trains) {
  var notes = [];
  for (var i = 0; i < trains.length; i++) {
    if (trains[i].delay > 0) {
      var note = `${trains[i].name} is ${trains[i].delay} minutes late`;
    }
    notes.push(note || `${trains[i].name} is on time`);
  }
  return notes;
}
The sample tests · boarding.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { boardingCalls, delayNotes } from './boarding.mjs';

test('each call announces its own coach', () => {
  const calls = boardingCalls(['S1', 'S2', 'B1']);
  assert.deepEqual(
    calls.map((call) => call()),
    ['Coach S1 is now boarding', 'Coach S2 is now boarding', 'Coach B1 is now boarding'],
  );
});

test('one call per coach, and none for an empty train', () => {
  assert.equal(boardingCalls(['A1', 'A2']).length, 2);
  assert.equal(boardingCalls([]).length, 0);
});

test('a train on time after a late one gets its own line', () => {
  const trains = [
    { name: 'Rajdhani', delay: 25 },
    { name: 'Shatabdi', delay: 0 },
    { name: 'Duronto', delay: 5 },
    { name: 'Garib Rath', delay: 0 },
  ];
  assert.deepEqual(delayNotes(trains), [
    'Rajdhani is 25 minutes late',
    'Shatabdi is on time',
    'Duronto is 5 minutes late',
    'Garib Rath is on time',
  ]);
});

test('every train on time, and no trains at all', () => {
  assert.deepEqual(delayNotes([{ name: 'Mail', delay: 0 }, { name: 'Express', delay: 0 }]), ['Mail is on time', 'Express is on time']);
  assert.deepEqual(delayNotes([]), []);
});

test('neither function declares a variable with var', () => {
  for (const fn of [boardingCalls, delayNotes]) {
    assert.ok(!/\bvar\s/.test(String(fn)), `${fn.name} still declares a variable with var`);
  }
});
A hint

A loop counter declared with let gets a fresh copy on every pass, which is what each boarding call needs to remember its own coach. In delayNotes, the note belongs to one train: declare it inside the loop body, before the if, so every pass starts without the previous train's note. Changing var note to let note inside the if alone is not enough, because the name then does not exist below the if block.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 What happens when this module runs?

    Read the code, then choose one answer.

    const settings = { theme: 'dark' };
    settings.theme = 'light';
    settings = { theme: 'blue' };
    Show the answer to question 1

    Answer: The third line throws a TypeError; by then settings.theme is already 'light'.

    const protects the binding, not the object. Changing a property of the object leaves the name pointing at the same object, so the second line is fine. The third line tries to point the name at a new object, which a const binding refuses at run time with a TypeError.

  2. Question 2 of 5 A loop adds the price of every item in a cart to a running total. How should the total be declared?

    Choose one answer.

    Show the answer to question 2

    Answer: let total = 0;

    The total gets a new value on every pass of the loop, so it needs a binding that can be reassigned: let. With const, the first addition throws a TypeError. var would work but brings function scope and the other old rules; and in a module an undeclared total = 0 throws a ReferenceError.

  3. Question 3 of 5 In a block, the line console.log(typeof price) comes before let price = 10;. What happens?

    Choose one answer.

    Show the answer to question 3

    Answer: It throws a ReferenceError.

    price exists from the start of the block but is in its temporal dead zone until the declaration runs, and any use of it there throws a ReferenceError, typeof included. typeof returns 'undefined' only for a name that is not declared anywhere.

  4. Question 4 of 5 What does this program print?

    What does this program print? Choose one answer.

    // Each function remembers the variable i, and reads it later, when it is called.
    const withLet = [];
    for (let i = 1; i <= 3; i++) {
      withLet.push(() => i); // let: every pass of the loop gets its own i
    }
    console.log('let:', withLet.map((read) => read()).join(' '));
    
    const withVar = [];
    for (var j = 1; j <= 3; j++) {
      withVar.push(() => j); // var: one j for the whole loop, 4 when the loop ends
    }
    console.log('var:', withVar.map((read) => read()).join(' '));
    Show the answer to question 4

    Answer: it prints

    let: 1 2 3
    var: 4 4 4

    With let, every pass of the loop gets its own copy of i, and each function keeps the copy of its own pass. With var there is one j for the whole loop, and when the functions are finally called the loop has left it at 4.

  5. Question 5 of 5 Which of these are valid variable names?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • _count
    • $total
    • नाम
    • seatsLeft

    A name starts with a letter of any script, $ or _, and continues with letters, digits, $ and _. 2ndTrain starts with a digit, and class is a reserved word. नाम is valid, although ASCII names are easier for a team to share.

Interview questions

What is the difference between let, const and var? All three declare variables, but they differ in scope, reassignment and what happens before the declaration runs. const and let are block-scoped: they exist only inside the braces that contain them. Using either before its declaration throws a ReferenceError (the temporal dead zone), and declaring the same name twice in one scope is a SyntaxError. const must be initialised and can never be reassigned, although the object it refers to can still be changed. let can be reassigned. var is function-scoped, ignores blocks, can be redeclared and reads as undefined before its declaration, and in a for loop all passes share one var variable. Modern code uses const by default and let when a value must change.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A teammate says the settings object exported with const is safe because const makes it immutable, yet a bug report shows that another module changed settings.api.timeout at run time. Explain what const really guarantees, and how you would make the shared settings read-only.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0) prints 3 three times. Explain exactly why, give two different fixes, and say what let changes in how the loop works.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • const creates a binding that can never be pointed at another value; let creates one that can be reassigned.
  • const protects the binding, not the value: an array or object declared with const can still change. Object.freeze makes an object read-only, one level deep.
  • let and const are block-scoped, and a for loop with let gives each pass its own copy of the counter.
  • Reading a let or const name before its declaration has run throws a ReferenceError, even with typeof.
  • In a module, a typo in a name throws instead of creating a global, and declaring a name twice stops the module before it runs.
  • Use const by default, let when a value must change, camelCase names that say what a value means, and no var.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.