Skip to main content
JavaScript · lesson 3 of 24
JavaScript beginner Lesson 3 of 24

Variables 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

Featurevarletconst
ScopeFunctionBlockBlock
HoistedYes (as undefined)TDZTDZ
ReassignableYesYesNo
Re-declarable in same scopeYesNoNo
Use in modern codeAvoidWhen neededDefault

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

  1. TDZ error: What happens when this runs?

    console.log(score);
    let score = 100;
  2. Nullish vs OR default: process.env.PORT is "0" (an environment variable is always a string). What do port and port2 produce?

    const port = process.env.PORT || 3000;
    const port2 = process.env.PORT ?? 3000;
  3. Destructure with defaults: What value does port have?

    const config = { host: "localhost", retries: 3 };
    const { host, port = 5432 } = config;
  4. const array mutation: How do you add 4 to items without reassigning the binding?

    const items = [1, 2, 3];
  5. 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.

Frequently Asked Questions

Should I ever use var in new code?
No. var has surprising hoisting behavior and function-level scoping that causes real bugs. Use const by default and let when you need to reassign. Reserve var for legacy codebases you're maintaining.
Can I change the properties of a const object?
Yes. const prevents reassigning the variable binding, not mutating the value. You can still push to a const array or update properties on a const object. Object.freeze() blocks changes to an object's own properties, but it is shallow, and in sloppy mode a blocked write fails silently instead of throwing.
What is the temporal dead zone?
The period between the start of a block scope and the point where a let or const variable is declared. Accessing the variable in this zone throws a ReferenceError — unlike var, which returns undefined.
Why does { a, b } = obj; throw a SyntaxError in JavaScript?
A statement that starts with { is parsed as a block, so the = after it is an unexpected token. Wrapping it as ({ a, b } = obj); makes it a destructuring assignment. If a and b were never declared, that creates implicit globals in sloppy-mode scripts and throws a ReferenceError in strict mode and ES modules. Declare them with const or let instead.