JavaScript · lesson 3 of 24
Beginner
11- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Arrays in JavaScript
- 10 Objects in JavaScript
- 18 DOM Manipulation in JavaScript
Intermediate
10- 11 Error Handling in JavaScript
- 12 Object-Oriented Programming in JavaScript
- 13 Prototypes and Prototype Chain in JavaScript
- 14 Closures in JavaScript
- 15 JavaScript Event Loop Explained
- 16 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 17 JavaScript Modules: ESM and CommonJS
- 19 Browser Web APIs in JavaScript
- 20 Testing JavaScript with Jest and Vitest
- 24 JavaScript Interview Preparation: Top 35 Questions
Advanced
3Variables in JavaScript: var, let, and const
Understand the differences between var, let, and const — including hoisting, temporal dead zone, block scope, and when to use each one.
By SkillByExample · Updated
Three Ways to Declare Variables
Variables are named containers that let your program store and reuse values. Without them you’d have to repeat every value everywhere, making code impossible to maintain. JavaScript has three keywords for declaring variables — var, let, and const — and they differ meaningfully in scope, hoisting behavior, and whether the binding can be reassigned. Choosing the right one makes your intent clear to anyone reading the code.
var name = "Alice"; // function-scoped, hoisted
let age = 30; // block-scoped
const PI = 3.14159; // block-scoped, can't be reassigned
console.log(name, age, PI);
$ node declare.js
Alice 30 3.14159
All three declare a name and give it a value, and in a script this short they behave identically. The differences only show up when you read a variable before its line, use it outside its block, or try to assign it again. Each section below runs one of those cases. Unless a section says otherwise, the files are plain .js, which Node runs as CommonJS when no package.json sets "type": "module".
var — Function Scope and Hoisting
var was the original variable declaration and is still valid JavaScript, but it comes with behavior that causes subtle bugs. The core issue is that var scopes to the enclosing function, not to the nearest block — so an if statement or a for loop does not create a new scope for var variables.
Function scope (not block scope)
function checkAge(age) {
if (age >= 18) {
var message = "Adult";
}
// message is accessible here — var ignores the if block
console.log(message);
}
checkAge(20);
checkAge(15);
$ node function-scope.js
Adult
undefined
The second call is the surprise: the if body never ran, yet console.log(message) didn’t throw. With var, the if block creates no new scope, so message belongs to the whole function. It exists from the start of every call and stays undefined unless the assignment runs.
Hoisting
JavaScript moves var declarations to the top of their function scope at compile time. Only the declaration is hoisted, not the assignment. This means you can reference a var variable before its line in the source without getting an error — you just get undefined, which is a silent bug that can be very hard to track down.
console.log(city);
var city = "Tokyo";
console.log(city);
$ node hoisting.js
undefined
Tokyo
The first line reads city before its declaration and gets undefined, not a ReferenceError. The engine treats the file as if var city; were written at the top, while the = "Tokyo" assignment stays on line 2. This is the classic var trap: a read that comes too early fails silently instead of loudly.
var in loops — a famous bug
Because var is function-scoped, every iteration of a loop shares the exact same variable. Closures inside the loop all capture the same reference, so by the time any callback runs, the loop has already finished and the variable holds its final value.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
$ node var-loop.js
3
3
3
The callbacks run 100 ms later, after the loop has finished. There is only one i, and by then it holds 3, the value that ended the loop. Change one word, var to let:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
$ node let-loop.js
0
1
2
A let in a for header creates a fresh binding for each iteration, so each callback closes over its own i. This was one of the most common JavaScript bugs before let was introduced.
let — Block Scope
let was introduced in ES2015 and solves most of var’s problems. It scopes to the nearest enclosing block — any pair of curly braces {} — which is the intuitive behavior most programmers expect from other languages. Use let whenever you need a variable whose value will change after it is first assigned.
function getDiscount(isMember) {
if (isMember) {
let discount = 0.2;
console.log("inside the block:", discount);
}
console.log("after the block:", discount);
}
getDiscount(true);
$ node block-scope.js
inside the block: 0.2
C:\tmp\jsvars\block-scope.js:6
console.log("after the block:", discount);
^
ReferenceError: discount is not defined
at getDiscount (C:\tmp\jsvars\block-scope.js:6:35)
at Object.<anonymous> (C:\tmp\jsvars\block-scope.js:9:1)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
This is the same shape as the var example, but now line 6 throws. discount exists only between the if block’s braces, so once the block closes the name is gone. Compare checkAge(15) above, which quietly printed undefined.
Temporal Dead Zone (TDZ)
A let variable exists from the start of its block, but reading it before its declaration line throws a ReferenceError. Here an outer score is already set, and the inner block declares its own:
let score = 50;
{
console.log(score);
let score = 100;
}
$ node tdz.js
C:\tmp\jsvars\tdz.js:4
console.log(score);
^
ReferenceError: Cannot access 'score' before initialization
at Object.<anonymous> (C:\tmp\jsvars\tdz.js:4:15)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
You might expect line 4 to print the outer 50. But the inner let score claims the name for the whole block, starting at the opening brace. Until line 5 runs, that inner score is uninitialised, so reading it throws. The stretch from the start of the block to the declaration is the temporal dead zone. It is deliberate: the bug fails loudly instead of quietly returning undefined the way var does.
let is reassignable
let count = 0;
count = count + 1;
count++;
console.log(count);
$ node let-reassign.js
2
Both forms of reassignment work on a let. That ability is the one thing let has that const lacks.
const — Block Scope with Immutable Binding
const shares let’s block-scoping and TDZ behavior, but adds one constraint: the variable binding cannot be reassigned after initialization. This matters because most variables in well-written code are set once and never reassigned — using const signals that intent explicitly, making the code easier to follow and preventing accidental overwrites.
const MAX_RETRIES = 3;
MAX_RETRIES = 5;
console.log(MAX_RETRIES);
$ node const-reassign.js
C:\tmp\jsvars\const-reassign.js:2
MAX_RETRIES = 5;
^
TypeError: Assignment to constant variable.
at Object.<anonymous> (C:\tmp\jsvars\const-reassign.js:2:13)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
The error is a TypeError raised when line 2 runs, not a syntax error when the file loads, so any code above that line would still have run. Line 3 never runs. This happens in sloppy mode too: assigning to a const throws in every mode.
const does not mean the value is immutable
This is the most common misconception about const. The keyword protects the binding (the connection between the name and the value), not the value itself.
const user = { name: "Alice", age: 30 };
user.age = 31;
user.role = "admin";
console.log(user);
user = { name: "Bob" };
$ node const-object.js
{ name: 'Alice', age: 31, role: 'admin' }
C:\tmp\jsvars\const-object.js:7
user = { name: "Bob" };
^
TypeError: Assignment to constant variable.
at Object.<anonymous> (C:\tmp\jsvars\const-object.js:7:6)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
Changing one property and adding another both worked, and the object printed with its new contents. Only line 7, which points user at a different object, threw. For an object whose properties can’t change, use Object.freeze. Its behaviour depends on strict mode, so this file runs once as CommonJS (.cjs, sloppy) and once as an ES module (.mjs, always strict):
const config = Object.freeze({ apiUrl: "https://api.example.com", timeout: 5000 });
config.timeout = 9999;
console.log(config.timeout);
$ node freeze.cjs
5000
$ node freeze.mjs
file:///C:/tmp/jsvars/freeze.mjs:2
config.timeout = 9999;
^
TypeError: Cannot assign to read only property 'timeout' of object '#<Object>'
at file:///C:/tmp/jsvars/freeze.mjs:2:16
at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
at async node:internal/modules/esm/loader:643:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.18.1
In sloppy mode the write is ignored without an error, so 5000 prints and the bug goes unnoticed. In strict mode the same write throws. Object.freeze is also shallow: a nested object inside config would still be writable. The same binding-versus-value rule applies to arrays:
const items = [1, 2, 3];
items.push(4);
console.log(items);
items = [5, 6, 7];
$ node const-array.js
[ 1, 2, 3, 4 ]
C:\tmp\jsvars\const-array.js:5
items = [5, 6, 7];
^
TypeError: Assignment to constant variable.
at Object.<anonymous> (C:\tmp\jsvars\const-array.js:5:7)
at Module._compile (node:internal/modules/cjs/loader:1871:14)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
push changes the array the name points to, so it is allowed. items = [...] tries to point the name at a new array, so it throws.
Side-by-Side Comparison
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes (as undefined) | TDZ | TDZ |
| Reassignable | Yes | Yes | No |
| Re-declarable in same scope | Yes | No | No |
| Use in modern code | Avoid | When needed | Default |
When to Use Each
Use const by default. It signals to readers that this value doesn’t change, which reduces cognitive load. Most variables in well-written JavaScript are const.
This example uses top-level await, so it runs as an ES module (.mjs):
const BASE_URL = "https://api.example.com";
const users = []; // reference doesn't change, contents may
const fetchUser = async (id) => ({ id, url: `${BASE_URL}/users/${id}` });
users.push(await fetchUser(1));
users.push(await fetchUser(2));
console.log(users);
$ node const-default.mjs
[
{ id: 1, url: 'https://api.example.com/users/1' },
{ id: 2, url: 'https://api.example.com/users/2' }
]
None of the three names is ever reassigned, not even users, which grows by two entries. A constant string, an array you add to and a function all belong in const.
Use let when you genuinely need to reassign. Counters, accumulators, and loop variables are the clearest cases. Here fetchData fails twice before it succeeds (also .mjs, for the top-level await):
const MAX_RETRIES = 3;
let calls = 0;
async function fetchData() {
calls++;
if (calls < 3) throw new Error(`attempt ${calls} failed`);
return "data";
}
let retries = 0;
while (retries < MAX_RETRIES) {
try {
console.log("got", await fetchData(), "after", retries, "retries");
break;
} catch (err) {
console.log(err.message);
retries++;
}
}
$ node retry.mjs
attempt 1 failed
attempt 2 failed
got data after 2 retries
retries and calls must change as the loop runs, so they are let. MAX_RETRIES never changes, so it is const.
Avoid var in new code. There is no modern use case where var is preferable to let or const.
Common Pitfalls
Confusing const immutability with object immutability
const settings = { theme: "dark" };
settings.theme = "light";
console.log(settings);
$ node settings.js
{ theme: 'light' }
This surprises many beginners. The const was never reassigned, so nothing threw, but the object it points to did change.
Re-declaring with let in the same scope
let x = 1;
let x = 2;
console.log(x);
$ node redeclare.js
C:\tmp\jsvars\redeclare.js:2
let x = 2;
^
SyntaxError: Identifier 'x' has already been declared
at wrapSafe (node:internal/modules/cjs/loader:1804:18)
at Module._compile (node:internal/modules/cjs/loader:1845:20)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
This is a SyntaxError, raised while the file is compiled (wrapSafe), so not even line 1 runs. Swap both lets for var and the same file runs:
var x = 1;
var x = 2;
console.log(x);
$ node var-redeclare.js
2
var lets the second declaration silently overwrite the first. The let error is a feature, because it catches copy-paste bugs that var allows.
Forgetting const in destructuring
Drop the keyword from a destructuring declaration and the line no longer declares anything. Run it as CommonJS (.cjs), which is sloppy mode by default:
function getUser() {
return { name: "Alice", age: 30 };
}
{ name, age } = getUser();
console.log(name, age);
$ node no-parens.cjs
C:\tmp\jsvars\no-parens.cjs:5
{ name, age } = getUser();
^
SyntaxError: Unexpected token '='
at wrapSafe (node:internal/modules/cjs/loader:1804:18)
at Module._compile (node:internal/modules/cjs/loader:1845:20)
at Object..js (node:internal/modules/cjs/loader:2002:10)
at Module.load (node:internal/modules/cjs/loader:1594:32)
at Module._load (node:internal/modules/cjs/loader:1396:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:255:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47
Node.js v24.18.1
A statement that starts with { is parsed as a block, not an object pattern, so { name, age } is a block holding the expression name, age, and the = after it is a syntax error. Nothing runs, so no globals are created. To destructure into existing variables you have to wrap the whole assignment in parentheses:
function getUser() {
return { name: "Alice", age: 30 };
}
({ name, age } = getUser());
console.log(name, age);
console.log(globalThis.name, globalThis.age);
$ node parens.cjs
Alice 30
Alice 30
$ node parens.mjs
file:///C:/tmp/jsvars/parens.mjs:5
({ name, age } = getUser());
^
ReferenceError: name is not defined
at file:///C:/tmp/jsvars/parens.mjs:5:4
at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
at async node:internal/modules/esm/loader:643:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.18.1
The same code gives two different results. As sloppy-mode CommonJS (parens.cjs), assigning to undeclared names quietly creates implicit globals: name and age are now properties of globalThis, visible to every file in the process. As an ES module (parens.mjs), which is always strict mode, assigning to an undeclared name throws a ReferenceError. A "use strict" line at the top of the .cjs file has the same effect. The fix in both cases is to declare the names:
function getUser() {
return { name: "Alice", age: 30 };
}
const { name, age } = getUser();
console.log(name, age);
console.log(globalThis.name, globalThis.age);
$ node declared.mjs
Alice 30
undefined undefined
With const (or let if you’ll reassign), name and age are ordinary module-scoped variables, and nothing leaks onto globalThis.
Practice
-
TDZ error: What happens when this runs?
console.log(score); let score = 100; -
Nullish vs OR default:
process.env.PORTis"0"(an environment variable is always a string). What doportandport2produce?const port = process.env.PORT || 3000; const port2 = process.env.PORT ?? 3000; -
Destructure with defaults: What value does
porthave?const config = { host: "localhost", retries: 3 }; const { host, port = 5432 } = config; -
const array mutation: How do you add
4toitemswithout reassigning the binding?const items = [1, 2, 3]; -
var scope trap: What prints? Fix it with
let.function checkAge(age) { if (age >= 18) { var message = "Adult"; } console.log(message); // ? } checkAge(15);
Solution 1
It throws ReferenceError: Cannot access 'score' before initialization. The temporal dead zone makes the bug visible instead of silently returning undefined.
Solution 2
Both are "0" — the string, not a number. process.env.PORT is always a string, and "0" is a non-empty string, which is truthy and not nullish. So "0" || 3000 evaluates to "0" (the left side is truthy, || never reaches the fallback), and "0" ?? 3000 also evaluates to "0" (the left side isn’t null/undefined, so ?? never reaches the fallback either). This is the classic gotcha with env-var ports: neither || nor ?? protects you here — you’d need Number(process.env.PORT ?? 3000) to reliably get a number.
Solution 3
5432 — the default applies because config.port is undefined. The default does not apply if port were 0 or "".
Solution 4
items.push(4); // mutates the array contents; the const binding is unchanged
Solution 5
It prints undefined — var is function-scoped, not block-scoped. Fix:
function checkAge(age) {
let message;
if (age >= 18) {
message = "Adult";
}
console.log(message); // undefined
}
What’s Next
Now that you understand how variables are stored, the next tutorial covers JavaScript’s data types — the different kinds of values you can put inside those variables.