ES6+ FeaturesFeatured

JavaScript let and const: Block-Scoped Variables

Understand let and const in JavaScript. Learn block scoping, temporal dead zone, when to use each, and how they differ from var.

By JavaScriptDoc Team
letconstvariableses6scope

JavaScript let and const: Block-Scoped Variables

ES6 introduced let and const as alternatives to var, bringing block-scoping to JavaScript and solving many common issues with variable declarations. Understanding these keywords is fundamental to writing modern JavaScript.

Understanding Block Scope

Block scope means variables are only accessible within the block where they're defined.

// var has function scope
function varExample() {
  if (true) {
    var x = 1;
  }
  console.log(x); // 1 - accessible outside block
}

// let has block scope
function letExample() {
  if (true) {
    let x = 1;
  }
  // console.log(x); // ReferenceError: x is not defined
}

// const has block scope
function constExample() {
  if (true) {
    const x = 1;
  }
  // console.log(x); // ReferenceError: x is not defined
}

// Block scope in loops
for (let i = 0; i < 3; i++) {
  // i is only accessible here
}
// console.log(i); // ReferenceError

for (var j = 0; j < 3; j++) {
  // j uses function scope
}
console.log(j); // 3 - accessible outside loop

let: Reassignable Block-Scoped Variables

Basic Usage

// Declaration and initialization
let name = 'John';
let age = 30;
let isActive = true;

// Reassignment
name = 'Jane'; // OK
age = 31; // OK
isActive = false; // OK

// Declaration without initialization
let score; // undefined
score = 100; // OK

// Block scoping
function processData() {
  let result = 0;

  if (true) {
    let innerResult = 10;
    result = innerResult; // OK
  }

  // console.log(innerResult); // Error: not defined
  console.log(result); // 10
}

Loop Behavior

// let in loops creates new binding for each iteration
const functions = [];

for (let i = 0; i < 3; i++) {
  functions.push(() => i);
}

console.log(functions[0]()); // 0
console.log(functions[1]()); // 1
console.log(functions[2]()); // 2

// Compare with var
const varFunctions = [];

for (var j = 0; j < 3; j++) {
  varFunctions.push(() => j);
}

console.log(varFunctions[0]()); // 3
console.log(varFunctions[1]()); // 3
console.log(varFunctions[2]()); // 3

const: Immutable Bindings

Basic Usage

// Must be initialized at declaration
const PI = 3.14159;
const MAX_SIZE = 100;
const API_URL = 'https://api.example.com';

// Cannot be reassigned
// PI = 3.14; // TypeError: Assignment to constant variable

// Cannot be declared without initialization
// const x; // SyntaxError: Missing initializer

// Block scoped like let
if (true) {
  const SECRET = 'hidden';
}
// console.log(SECRET); // ReferenceError

Object and Array Mutability

// const prevents reassignment, not mutation
const person = {
  name: 'John',
  age: 30,
};

// This is NOT allowed
// person = { name: 'Jane' }; // TypeError

// But this IS allowed (mutation)
person.name = 'Jane'; // OK
person.age = 31; // OK
person.email = 'jane@example.com'; // OK

// Arrays
const numbers = [1, 2, 3];

// Not allowed
// numbers = [4, 5, 6]; // TypeError

// Allowed (mutation)
numbers.push(4); // OK
numbers[0] = 10; // OK
numbers.pop(); // OK

// To prevent mutation, use Object.freeze
const frozen = Object.freeze({
  name: 'John',
  age: 30,
});

frozen.name = 'Jane'; // Silently fails (throws in strict mode)
console.log(frozen.name); // 'John'

// Deep freeze for nested objects
function deepFreeze(obj) {
  Object.freeze(obj);
  Object.values(obj).forEach((value) => {
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value);
    }
  });
  return obj;
}

Temporal Dead Zone (TDZ)

The temporal dead zone is the time between entering a scope and the variable declaration.

// Temporal Dead Zone example
console.log(myVar); // undefined (hoisted)
console.log(myLet); // ReferenceError: Cannot access before initialization
console.log(myConst); // ReferenceError: Cannot access before initialization

var myVar = 1;
let myLet = 2;
const myConst = 3;

// TDZ in action
function tdxExample() {
  // TDZ starts
  console.log(typeof x); // ReferenceError
  console.log(typeof y); // ReferenceError

  let x = 1; // TDZ ends for x
  const y = 2; // TDZ ends for y
}

// TDZ with default parameters
function example(a = b, b) {
  // Error: b is in TDZ when a is initialized
}

// Correct order
function correctExample(b, a = b) {
  // OK: b is already initialized
}

Redeclaration Rules

// var allows redeclaration
var x = 1;
var x = 2; // OK

// let doesn't allow redeclaration in same scope
let y = 1;
// let y = 2; // SyntaxError: Identifier 'y' has already been declared

// const doesn't allow redeclaration
const z = 1;
// const z = 2; // SyntaxError

// Different scopes are OK
let a = 1;
{
  let a = 2; // OK - different scope
  console.log(a); // 2
}
console.log(a); // 1

// Shadowing is allowed
const value = 'outer';
function test() {
  const value = 'inner'; // OK - shadows outer
  console.log(value); // 'inner'
}
test();
console.log(value); // 'outer'

When to Use let vs const vs var

Use const by Default

// Prefer const for values that won't be reassigned
const API_KEY = 'abc123';
const MAX_RETRIES = 3;
const calculateTax = (price) => price * 0.08;

// Use const for objects and arrays (even if mutated)
const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
};

const users = [];
users.push({ name: 'John' }); // Mutation is OK

// Use const in loops when possible
const items = [1, 2, 3, 4, 5];
for (const item of items) {
  console.log(item); // Each iteration gets new const binding
}

Use let When Reassignment is Needed

// Counters and accumulators
let count = 0;
for (const item of items) {
  count++; // Needs reassignment
}

// Conditional initialization
let message;
if (user.isActive) {
  message = 'Welcome back!';
} else {
  message = 'Please activate your account';
}

// Loop counters
for (let i = 0; i < array.length; i++) {
  // i needs to be incremented
}

// Swapping values
let a = 1;
let b = 2;
[a, b] = [b, a]; // Swap

Avoid var in Modern Code

// Don't use var - it has confusing scoping rules
var x = 1; // Function scoped
let y = 1; // Block scoped - clearer intent

// var hoisting can cause bugs
console.log(myVar); // undefined (not error)
var myVar = 5;

// var in loops creates closure issues
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100); // Prints 5 five times
}

// Use let instead
for (let j = 0; j < 5; j++) {
  setTimeout(() => console.log(j), 100); // Prints 0,1,2,3,4
}

Practical Examples

Configuration Objects

// Application configuration
const CONFIG = {
  API_BASE_URL: 'https://api.example.com',
  API_VERSION: 'v1',
  TIMEOUT: 30000,
  RETRY_ATTEMPTS: 3,

  get apiEndpoint() {
    return `${this.API_BASE_URL}/${this.API_VERSION}`;
  },
};

// Environment-based config
const ENV = process.env.NODE_ENV || 'development';
const IS_PRODUCTION = ENV === 'production';
const DEBUG = !IS_PRODUCTION;

// Feature flags
const FEATURES = {
  DARK_MODE: true,
  BETA_FEATURES: false,
  ANALYTICS: IS_PRODUCTION,
};

State Management

// Component state pattern
function createCounter() {
  let count = 0; // Private state

  const increment = () => {
    count++;
    render();
  };

  const decrement = () => {
    count--;
    render();
  };

  const render = () => {
    console.log(`Count: ${count}`);
  };

  return { increment, decrement };
}

// Game state
const gameState = {
  score: 0,
  level: 1,
  lives: 3,
  isPlaying: false,
};

function updateGameState(updates) {
  Object.assign(gameState, updates);
}

Loop Patterns

// Processing arrays
const numbers = [1, 2, 3, 4, 5];

// Transform with const
const doubled = [];
for (const num of numbers) {
  doubled.push(num * 2);
}

// Accumulate with let
let sum = 0;
for (const num of numbers) {
  sum += num;
}

// Index-based loops
for (let i = 0; i < numbers.length; i++) {
  console.log(`Index ${i}: ${numbers[i]}`);
}

// Async operations in loops
async function processItems(items) {
  const results = [];

  for (const item of items) {
    const result = await processItem(item);
    results.push(result);
  }

  return results;
}

Common Patterns and Best Practices

Destructuring with const/let

// Object destructuring
const user = { name: 'John', age: 30, email: 'john@example.com' };
const { name, age } = user; // const by default

// Array destructuring
const coordinates = [10, 20];
const [x, y] = coordinates; // const by default

// Destructuring in function parameters
function processUser({ name, age }) {
  // name and age are const within function
  console.log(`${name} is ${age} years old`);
}

// Reassignment with let
let { status } = response;
status = 'updated'; // OK with let

Module-Level Constants

// constants.js
export const API_ENDPOINTS = {
  USERS: '/api/users',
  POSTS: '/api/posts',
  COMMENTS: '/api/comments',
};

export const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  NOT_FOUND: 404,
  SERVER_ERROR: 500,
};

export const REGEX_PATTERNS = {
  EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
  PHONE: /^\+?[\d\s-()]+$/,
  URL: /^https?:\/\/.+$/,
};

Error Messages and Enums

// Error messages
const ERROR_MESSAGES = {
  NETWORK_ERROR: 'Network request failed',
  INVALID_INPUT: 'Invalid input provided',
  UNAUTHORIZED: 'You are not authorized to perform this action',
  NOT_FOUND: 'The requested resource was not found',
};

// Enum-like constants
const UserRole = {
  ADMIN: 'admin',
  USER: 'user',
  GUEST: 'guest',
};

const Status = {
  PENDING: 'pending',
  APPROVED: 'approved',
  REJECTED: 'rejected',
};

// Freeze to prevent modification
Object.freeze(UserRole);
Object.freeze(Status);

Migration Guide: var to let/const

// Before (var)
var count = 0;
for (var i = 0; i < items.length; i++) {
  var item = items[i];
  if (item.active) {
    count++;
  }
}

// After (let/const)
let count = 0;
for (let i = 0; i < items.length; i++) {
  const item = items[i];
  if (item.active) {
    count++;
  }
}

// Or better with for...of
let count = 0;
for (const item of items) {
  if (item.active) {
    count++;
  }
}

// Function refactoring
// Before
function oldProcess() {
  var result = null;
  var temp;

  if (condition) {
    temp = getValue();
    result = transform(temp);
  }

  return result;
}

// After
function newProcess() {
  if (condition) {
    const temp = getValue();
    return transform(temp);
  }

  return null;
}

Best Practices

  1. Use const by default, let when needed

    const DEFAULT_TIMEOUT = 5000; // Won't change
    let retryCount = 0; // Will be incremented
    
  2. Declare variables at the top of their scope

    function process() {
      const config = getConfig();
      const logger = getLogger();
      let result;
    
      // Use variables...
    }
    
  3. Use meaningful names for constants

    // Good
    const MAX_PASSWORD_LENGTH = 128;
    const API_TIMEOUT_MS = 30000;
    
    // Avoid
    const N = 128;
    const T = 30000;
    
  4. Group related constants

    const DATABASE = {
      HOST: 'localhost',
      PORT: 5432,
      NAME: 'myapp',
    };
    

Conclusion

let and const are essential for modern JavaScript:

  • const for values that won't be reassigned
  • let for values that will change
  • Block scoping prevents common bugs
  • Temporal dead zone catches errors early
  • No more var in modern code

Key takeaways:

  • Default to const for clearer intent
  • Use let only when reassignment is necessary
  • Understand the difference between reassignment and mutation
  • Leverage block scoping for cleaner code
  • Avoid var to prevent scoping confusion

Master let and const to write more predictable and maintainable JavaScript!