JavaScript Const

5 minute read

const is the default declaration keyword in modern JavaScript. It’s block-scoped like let, but it locks the binding — once you set it, you can’t reassign it. That single guarantee makes code dramatically easier to read: when you see const, you know the name will always point at the same value.

The Basics

const PI = 3.14159;
PI = 3.15;   // ❌ TypeError: Assignment to constant variable

You must initialize at declaration:

const x;   // ❌ SyntaxError: Missing initializer in const declaration

That’s the deal: declare-and-set in one step, never reassign.

What const Actually Locks

This is the most misunderstood part of const. It locks the binding (the name → value link), not the value itself. If the value is an object or array, you can still mutate its contents.

const user = { name: "Ada" };

user.name = "Linus";      // ✅ mutating the object — fine
user.role = "admin";      // ✅ adding a property — fine
console.log(user);        // { name: "Linus", role: "admin" }

user = { name: "Grace" }; // ❌ TypeError — can't rebind
       const user ─────► ┌────────────────┐
                         │ { name: "Ada" }│   ← contents can change
                         └────────────────┘
                                   ▲
                                   │
                  this arrow is locked
                  you can't point user at a different object

Same with arrays:

const tags = ["red", "blue"];

tags.push("green");   // ✅ mutates the array — fine
tags[0] = "orange";   // ✅ mutates an index — fine
tags.length = 0;      // ✅ clears the array — fine

tags = [];            // ❌ TypeError — can't reassign

Note: Don’t be fooled by the keyword. const is about reference stability, not value immutability.

True Immutability with Object.freeze()

If you genuinely want a value that cannot be mutated, freeze it:

const config = Object.freeze({
  apiUrl: "https://api.example.com",
  timeout: 5000,
});

config.timeout = 9000;     // ❌ silently ignored in non-strict mode
                           // ❌ throws TypeError in strict mode / modules
console.log(config.timeout); // 5000

Object.freeze() is shallow — nested objects are not frozen:

const settings = Object.freeze({
  theme: { dark: true },
});

settings.theme = "light";   // ❌ blocked (top-level)
settings.theme.dark = false; // ✅ works — nested object isn't frozen
console.log(settings.theme.dark); // false

For deep freezing you need a helper:

function deepFreeze(obj) {
  for (const key of Object.keys(obj)) {
    if (typeof obj[key] === "object" && obj[key] !== null) {
      deepFreeze(obj[key]);
    }
  }
  return Object.freeze(obj);
}
Mechanism Locks Binding Locks Top-level Props Locks Nested
const ✅ ❌ ❌
const + Object.freeze ✅ ✅ ❌
const + deepFreeze ✅ ✅ ✅

const in Loops

In for…of and for…in, each iteration introduces a new binding — so const is correct:

const items = ["a", "b", "c"];

for (const item of items) {    // ✅ const — new binding each iteration
  console.log(item);
}

for (const key of Object.keys({ a: 1, b: 2 })) {  // ✅ const works here too
  console.log(key);
}

But a classic for counter reassigns the same i, so it needs let:

for (const i = 0; i < 3; i++) {   // ❌ TypeError on i++
}

for (let i = 0; i < 3; i++) {     // ✅ correct
}

Block Scope — Same as let

const is block-scoped exactly like let:

if (true) {
  const message = "hidden";
}
console.log(message);   // ❌ ReferenceError

And it has the same temporal dead zone:

console.log(name);      // ❌ ReferenceError — TDZ
const name = "Ada";

When to Use const (Almost Always)

Default to const everywhere. You’ll be right ~90% of the time.

// ✅ Config
const API_BASE = "https://api.example.com";
const MAX_RETRIES = 3;

// ✅ Imported modules
const fs = require("fs");
import { useState } from "react";

// ✅ Function definitions
const greet = (name) => `Hi ${name}`;

// ✅ Derived values
const total = items.reduce((sum, i) => sum + i.price, 0);

// ✅ DOM references
const button = document.querySelector("#submit");

// ✅ Objects and arrays you'll mutate
const cart = [];
cart.push(item);   // works, because we're not reassigning cart

Switch to let only when you see a future line like x = something:

let attempt = 0;            // will increment in the loop
while (attempt < MAX) {
  // ...
  attempt += 1;             // ← reassignment, so let is correct
}

const vs let vs var — Quick Reference

  const let var
Scope Block Block Function
Reassignment ❌ ✅ ✅
Re-declaration in same scope ❌ ❌ ✅
Must initialize ✅ ❌ ❌
Hoisted ✅ (TDZ) ✅ (TDZ) ✅ (undefined)
Pollutes window at top level ❌ ❌ ✅
Use in new code ✅ default ✅ when needed ❌ never

Naming Conventions for Constants

Two patterns coexist:

// 1. Module-level "compile-time" constants — UPPER_SNAKE_CASE
const MAX_RETRIES = 3;
const API_BASE = "https://api.example.com";

// 2. Local / derived const values — camelCase (same as any variable)
const user = await fetchUser(id);
const isAdmin = user.role === "admin";

The rule of thumb: UPPER_SNAKE_CASE for true magic numbers and config you’d treat as enum-like; camelCase for runtime values that happen to be assigned once.

Real-World Example

// Top-of-module constants
const API_BASE = "https://api.example.com";
const PAGE_SIZE = 50;
const RETRY_BACKOFF_MS = Object.freeze([100, 250, 500, 1000]);

async function loadOrders(userId) {
  const url = `${API_BASE}/users/${userId}/orders?limit=${PAGE_SIZE}`;
  const orders = [];                    // const — we mutate, don't rebind

  for (let attempt = 0; attempt < RETRY_BACKOFF_MS.length; attempt++) {
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const data = await res.json();
      orders.push(...data.orders);      // mutate the const array — OK
      return orders;
    } catch (err) {
      const wait = RETRY_BACKOFF_MS[attempt];
      await new Promise(r => setTimeout(r, wait));
    }
  }

  throw new Error("Failed after retries");
}

Notice: const everywhere except attempt, which truly counts up.

Common Misconceptions

Misconception Reality
“const makes the value immutable” ❌ It only freezes the binding
“Arrays declared with const are read-only” ❌ You can push, pop, splice, reassign indices
“const is slower than let” ❌ Same performance; engines optimise both identically
“I should always use let because it’s flexible” ❌ Constraint helps readers; use const and only relax to let when needed
“const can’t have its value depend on a runtime expression” ❌ It can — const user = await fetchUser() is fine

Best Practices Checklist

  • ✅ Default to const. Always.
  • ✅ Use Object.freeze() (or a deep freeze) when you need real immutability
  • ✅ Use UPPER_SNAKE_CASE for module-level magic constants
  • ✅ Let prefer-const (ESLint rule) catch lets that never get reassigned
  • ❌ Don’t reach for let until you have a concrete reassignment in mind
  • ❌ Don’t assume const prevents object mutation
  • ❌ Don’t Object.freeze everything reflexively — it has runtime cost; reserve it for shared, exported, or genuinely-immutable values

Tip: A useful mental model — const is to JS what final is to Java or readonly is to C#. It’s about intent: “this name points at this thing and that’s not going to change.”

What’s Next?

You’ve nailed variable declarations. Now we’ll look at what kinds of values those variables can hold — strings, numbers, booleans, objects, and the seven primitive types JavaScript ships with.

← Previous: JS Let Next: JS Data Types →

Updated: