JavaScript Variables — let, const, var, Scope, and Hoisting

A modern, ES6+ first guide to declaring variables in JavaScript — when to use const, when to use let, why var still exists, and how scope, hoisting, and the temporal dead zone actually work.

Modern JavaScript gives you three ways to declare a variable — const, let, and var — and only one of them is the right default. This post is the practical guide a working developer needs in 2026: a modern-first comparison, the rules of scope, the truth about hoisting and the temporal dead zone, naming conventions, and the small set of cases where the legacy var keyword still appears in the wild.

What “variable” actually means

A variable is a named binding to a value. Once you declare it, the JavaScript engine reserves a slot in memory and lets you refer to that slot by name. You don’t manage the memory yourself — the engine’s garbage collector reclaims it when the variable is no longer reachable.

JavaScript is dynamically typed: the same binding can hold a string at one moment and an array the next. The type belongs to the value, not the variable.

1let x = 42;          // x holds a number
2x = 'forty-two';     // now x holds a string — perfectly legal
3x = [4, 2];          // now an array

The modern default: const, then let, never var

Since ES6 (2015) the recommended rule is simple:

  1. Use const by default. It declares a binding that cannot be reassigned.
  2. Use let only when you need to reassign. Loop counters, accumulators, conditional values.
  3. Never use var in new code. It exists only because the web cannot break old pages.
1const MAX_RETRIES = 3;       // never reassigned
2let attempts = 0;            // will be incremented
3attempts += 1;               // OK
4// MAX_RETRIES = 5;          // TypeError: Assignment to constant variable

const does not mean immutable

const prevents reassignment of the binding. It does not freeze the value. Objects and arrays declared with const can still be mutated.

1const user = { name: 'Aisha', age: 30 };
2user.age = 31;               // OK — mutating the object
3user.city = 'Karachi';       // OK — adding a property
4// user = { name: 'Bilal' };  // TypeError — reassigning the binding

If you need a value you cannot mutate either, use Object.freeze:

1const frozen = Object.freeze({ name: 'Aisha' });
2frozen.name = 'Bilal';       // silently ignored in sloppy mode, throws in strict mode

Side-by-side: var, let, const

Featurevarletconst
ScopeFunctionBlockBlock
HoistedYes (initialised to undefined)Yes (in TDZ)Yes (in TDZ)
Redeclaration in same scopeAllowedErrorError
ReassignmentAllowedAllowedError
Attaches to global objectYes (when declared at top level in a script)NoNo
IntroducedES1 (1997)ES6 (2015)ES6 (2015)

The single most important row is scope. var is scoped to the nearest enclosing function (or to the global scope). let and const are scoped to the nearest enclosing { ... } block, which is what every other mainstream language does.


Scope, the right way

A variable’s scope is the region of the program where its name is bound. JavaScript has three kinds of scope:

  1. Global scope — declared outside any function or block.
  2. Function scope — declared inside a function (this is what var always uses).
  3. Block scope — declared inside { ... }, including if, for, while, and bare blocks (this is what let/const use).

Block scope in action

1function priceFor(quantity) {
2  if (quantity > 10) {
3    const discount = 0.1;     // block-scoped to the if
4    return quantity * 9 * (1 - discount);
5  }
6  // discount is not visible here — ReferenceError
7  return quantity * 10;
8}

The same code with var leaks discount to the entire function — useful at the time, surprising ever since:

1function priceFor(quantity) {
2  if (quantity > 10) {
3    var discount = 0.1;       // function-scoped — visible below
4  }
5  console.log(discount);      // undefined when quantity <= 10
6  return quantity * 10;
7}

The classic for loop trap

The most cited reason to prefer let over var:

1// With var: all callbacks share the same i (3)
2for (var i = 0; i < 3; i++) {
3  setTimeout(() => console.log(i), 0);  // 3, 3, 3
4}
5
6// With let: each iteration gets its own i
7for (let i = 0; i < 3; i++) {
8  setTimeout(() => console.log(i), 0);  // 0, 1, 2
9}

Before ES6, the workaround was an IIFE (Immediately Invoked Function Expression) per iteration. let solves it with no boilerplate.

Scope chain

When you reference a name, the engine looks it up in the current scope, then the enclosing scope, then the next one out, all the way to the global scope. The first match wins.

 1const planet = 'Earth';                       // global
 2
 3function outer() {
 4  const continent = 'Asia';                   // outer scope
 5
 6  function inner() {
 7    const country = 'Pakistan';               // inner scope
 8    console.log(country, continent, planet);  // all three resolve via the chain
 9  }
10
11  inner();
12}

That chain is also why closures work — see Functions in depth — closures and this.

Visualising scope


Hoisting and the Temporal Dead Zone

Hoisting is the JavaScript engine’s habit of processing all declarations in a scope before executing any code in it. The practical effect depends on which keyword you used.

var hoisting

var declarations are hoisted and initialised to undefined. So this runs without throwing:

1console.log(name);   // undefined — not a ReferenceError
2var name = 'Aisha';
3console.log(name);   // 'Aisha'

The engine treats it as if you had written:

1var name;            // declaration hoisted, initialised to undefined
2console.log(name);   // undefined
3name = 'Aisha';      // assignment stays in place
4console.log(name);   // 'Aisha'

let and const hoisting — the Temporal Dead Zone (TDZ)

let and const are also hoisted, but they are not initialised. The window between the start of the block and the line where the declaration appears is the Temporal Dead Zone. Touching the binding inside the TDZ throws.

1console.log(name);   // ReferenceError: Cannot access 'name' before initialization
2let name = 'Aisha';

Functions are hoisted differently

Function declarations are fully hoisted — both the name and the body — which is why this works:

1greet();             // 'Hello'
2
3function greet() {
4  console.log('Hello');
5}

Function expressions assigned to var, let, or const follow the rules of their declaration keyword:

1greet();             // TypeError: greet is not a function (with var)
2var greet = function () { console.log('Hello'); };
3
4greet();             // ReferenceError (with let or const — TDZ)
5const greet = function () { console.log('Hello'); };

More on these distinctions in Function basics and the Advanced reference.


Strict mode and the death of implicit globals

In pre-ES5 JavaScript, assigning to an undeclared name silently created a global variable:

1function setup() {
2  total = 100;       // no var/let/const — leaks to the global object
3}
4setup();
5console.log(total);  // 100

ES5 introduced 'use strict', which turns this into a ReferenceError. ES6 modules (import/export) and ES6 class bodies are automatically strict — you cannot opt out. Modern code is therefore strict by default.

1'use strict';
2
3function setup() {
4  total = 100;       // ReferenceError: total is not defined
5}

If you are using ES modules (the recommended default — see JavaScript modules), strict mode is already on.


Naming variables

JavaScript identifiers must:

  1. Start with a letter, _, or $. The starting character cannot be a digit.
  2. Continue with letters, digits, _, or $. Unicode letters are allowed but stick to ASCII for portability.
  3. Not be a reserved word (return, class, import, this, etc.).

Conventions

KindConventionExample
Regular variables and functionscamelCaseuserName, calculateTotal
Classes and constructorsPascalCaseUserAccount, HttpClient
Module-level constants (true constants)UPPER_SNAKE_CASEMAX_RETRIES, API_BASE_URL
Private fields (ES2022)#name#secret, #counter
Library-private (older convention)_name_internalCache
 1const MAX_RETRIES = 3;
 2const apiBaseUrl = 'https://api.example.com';
 3
 4class UserAccount {
 5  #password;                                  // ES2022 private field
 6  constructor(name, password) {
 7    this.name = name;
 8    this.#password = password;
 9  }
10}

Pick descriptive names

1// Don't
2const d = new Date() - userStart;
3
4// Do
5const elapsedMs = new Date() - userStart;

Names are the most-read, least-rewritten part of any codebase. Spend time on them.


Destructuring: declaring many variables at once

ES6 added destructuring, which lets you declare and assign several variables from an object or array in one line:

 1const user = { name: 'Aisha', age: 30, city: 'Karachi' };
 2
 3// Object destructuring
 4const { name, city } = user;
 5console.log(name, city);                      // 'Aisha' 'Karachi'
 6
 7// With renaming and defaults
 8const { name: fullName, country = 'Pakistan' } = user;
 9
10// Array destructuring
11const [first, second] = ['a', 'b', 'c'];
12console.log(first, second);                   // 'a' 'b'
13
14// Swap two variables — no temp needed
15let a = 1, b = 2;
16[a, b] = [b, a];
17console.log(a, b);                            // 2 1

Destructuring is now the dominant way to pull values out of function arguments, API responses, and module imports.


Checking for undefined and null

A variable that exists but has not been assigned is undefined. A variable explicitly set to “no value” is null. The two are similar but not equal under strict comparison.

1let x;
2console.log(x);                  // undefined
3console.log(x === undefined);    // true
4console.log(x === null);         // false
5console.log(x == null);          // true — loose equality treats null and undefined as equal

The ?? (nullish coalescing) operator, added in ES2020, is the modern way to provide a fallback only when a value is null or undefined:

1const port = config.port ?? 3000;             // fallback only if port is null/undefined
2const empty = ''           ?? 'default';      // ''     — empty string is kept
3const zero  = 0            ?? 42;             // 0      — zero is kept

Compare that to the older || idiom, which falls back on any falsy value:

1const port = config.port || 3000;             // falls back if port is 0 too — usually a bug

See JavaScript conditionals for the full truthiness story.


When does var still appear?

You will see var in:

  • Pre-2015 code, transpiled output, and old StackOverflow answers.
  • Single-file <script> snippets in tutorials.
  • Polyfills targeting ES5 environments.
  • Code generated by some bundlers for legacy browser targets.

In greenfield code today, there is no reason to type var. Linters such as ESLint default to flagging it.


Public, private, and the IIFE pattern (legacy)

Before ES2022 added native private class fields (#field), the only way to hide state was a closure. The classic pattern is the IIFE:

 1const counter = (function () {
 2  let count = 0;                              // private, captured by closure
 3  return {
 4    increment() { count += 1; },
 5    value()     { return count; },
 6  };
 7})();
 8
 9counter.increment();
10counter.increment();
11console.log(counter.value());                  // 2
12console.log(counter.count);                    // undefined — count is not exposed

The modern equivalent uses a class with private fields:

 1class Counter {
 2  #count = 0;
 3  increment() { this.#count += 1; }
 4  get value() { return this.#count; }
 5}
 6
 7const c = new Counter();
 8c.increment();
 9console.log(c.value);                          // 1
10console.log(c.count);                          // undefined
11console.log(c.#count);                         // SyntaxError — private fields are not accessible from outside

See ES6 classes for the full story.


Summary

  • Use const by default; let when you must reassign; never var in new code.
  • const prevents reassignment, not mutation. Use Object.freeze for shallow immutability.
  • let and const are block-scoped; var is function-scoped.
  • All declarations are hoisted, but let and const sit in a Temporal Dead Zone until their declaration line executes — accessing them early throws.
  • Modern JavaScript (ES modules, classes) is automatically strict, so implicit globals are gone.
  • Use destructuring to declare many variables at once.
  • Use ?? for null/undefined fallbacks instead of ||.

For deeper material on what variables resolve against at runtime, see Advanced reference: execution context & hoisting and this and context.


Test your understanding

Attempted Questions: 0 / 8

Further reading


  1. Mozilla Developer Network. let. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let ↩︎

  2. Ecma International (2024). ECMA-262, 15th edition. https://tc39.es/ecma262/ ↩︎