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.
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.
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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.
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:
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?
| Criterion | const | let | var |
|---|---|---|---|
| Can be reassigned | no: TypeError | yes | yes |
| Needs a value in the declaration | yes | no: starts as undefined | no: starts as undefined |
| Scope | the enclosing block | the enclosing block | the whole function, ignoring blocks |
| Used before its declaration | ReferenceError | ReferenceError | gives undefined |
| Declared twice in one scope | SyntaxError | SyntaxError | allowed |
| A fresh copy for each pass of a for loop | yes (for…of and for…in) | yes | no: one variable for the whole loop |
| When to choose | The default for every name: most names never get a second value | Counters, running totals and values that are set in different branches | Only 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.
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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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:
/* 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.
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.
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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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. Mostconstvariables are not of this kind, so they keep ordinary camelCase names:const total = price * quantityis a value the program computed, not a setting. - Names that say what the value means.
seatsLefttells the next reader far more thansordata2. 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, soboardingCalls(['S1', 'S2'])[1]()is'Coach S2 is now boarding'. Today every function names a coach calledundefined.delayNotes(trains)returns one line per train:'<name> is <delay> minutes late', or'<name> is on time'when itsdelayis 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
-
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.
-
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.
Key takeaways
constcreates a binding that can never be pointed at another value;letcreates one that can be reassigned.constprotects the binding, not the value: an array or object declared withconstcan still change.Object.freezemakes an object read-only, one level deep.letandconstare block-scoped, and aforloop withletgives each pass its own copy of the counter.- Reading a
letorconstname before its declaration has run throws aReferenceError, even withtypeof. - 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
constby default,letwhen a value must change, camelCase names that say what a value means, and novar.
References
- ECMAScript 2026 Language Specification: Let and Const Declarations (Ecma International)
- ECMAScript 2026 Language Specification: CreatePerIterationEnvironment (Ecma International)
- ECMAScript 2026 Language Specification: Strict Mode Code (Ecma International)
- ECMAScript 2026 Language Specification: PutValue (Ecma International)
- ECMAScript 2026 Language Specification: Identifier Names (Ecma International)
- const (MDN Web Docs)
- let (MDN Web Docs)
- Guidelines for writing JavaScript code examples (MDN Web Docs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress