const binds, it doesn't freeze
The JavaScript binding rules behind const and let, plus the block-scope and Temporal Dead Zone semantics that govern how names work in modern code.
A teammate ships const config = { feature: true } at the top of a module and considers the value protected. Another module imports config, sets config.feature = false mid-request to short-circuit a code path, and the user-facing flag flips silently for everyone touching that request. const did nothing wrong: it never promised to freeze the value. It only promised that the name would keep pointing at the same object.
A different week brings a different problem. A binding declared let “just in case” six months ago, never reassigned since, finally gets reassigned by an unrelated change. Half the rendering pipeline downstream had read the original value once and assumed it wouldn’t move. Both bugs share the same root: a misunderstanding of what const actually promises, and a habit of reaching for let when nothing needs it.
By the end of this lesson you’ll have the one-sentence rule that closes both bug classes, the block-scope and Temporal Dead Zone semantics that keep modern JavaScript predictable, and the const-by-default habit that every later file in this course relies on. Keep in mind the binding diagram from the first lesson of this chapter: const locks the arrow, not the box.
const locks the arrow, not the box
Section titled “const locks the arrow, not the box”Here is the rule the rest of the lesson builds on: const is a binding that cannot be reassigned. It says nothing about whether the value the binding points at can be mutated. Those are two different promises about two different things, and const only makes the first one.
The picture is the one from the chapter opener: two bindings, each with an arrow, and either a value box or a shared object on the other end.
const makes that arrow permanent: you can’t repoint it at something else, and that’s all “no reassignment” means. The object box on the right-hand panel is a different story, because const has no opinion about it. If the value at the end of the arrow is mutable (and every object, array, Map, Set, and class instance is), then any code holding a reference can reach in and change its contents. The arrow stays put; the box changes underneath.
Here is the production bug from the opener, compressed into two lines.
const config = { feature: true };config.feature = false;// config = { feature: false };The second line works. It’s a mutation through the existing arrow: the code reaches into the object the arrow points at and changes one of its properties, and const has nothing to say about that. The third line, if you uncommented it, would throw TypeError: Assignment to constant variable. That line is a reassignment, an attempt to repoint the arrow at a different object, and reassignment is exactly what const blocks.
For a primitive, the distinction collapses, because a primitive has no inside to mutate: there’s no way to “change the contents” of 42 or 'hello'. So const x = 42 does behave as if the value itself were frozen, simply because the value can’t be mutated to begin with. The idea that const means a frozen value holds for primitives and breaks the moment the value is an object. That’s where most of the bugs come from: the mental model that worked for const PORT = 3000 quietly fails on const config = { feature: true }.
The defense isn’t Object.freeze, and this lesson gets to why later. The defense is the immutability habit from the first lesson of this chapter, copy-then-modify with spread or structuredClone, backed by the compile-time guards TypeScript ships, which Chapter 4, “Typing values you know,” covers properly. For now, hold onto the rule: the arrow is locked, the box is not.
const by default, let when the binding must change
Section titled “const by default, let when the binding must change”The habit to build is simple: write const for every binding unless the binding genuinely has to be reassigned. The trigger is not “the value might change”, because the value is the box and const doesn’t care about the box. The trigger is the binding itself needing to point at something different later, and that is the only reason to reach for let.
In practice, let earns its place in a small, well-defined set of cases:
- An accumulator in a manual loop, such as a
let total = 0;you increment as you walk a list. The binding has to reassign on every iteration; that reassignment is the whole point of the loop. (When the shape fits, the experienced reach is.reduce, which sidesteps the mutable binding entirely.) - A value that depends on a branch too gnarly for a ternary:
let role; if (...) { role = 'admin'; } else if (...) { role = 'editor'; } else { role = 'viewer'; }. This is rare in well-shaped code, and usually a sign that the branches should be extracted into a helper that returns the value, after which the call site isconst role = pickRole(...)again. - A loop counter in a classic
for (let i = 0; ...; i++). It comes up less often than you’d think in 2026 code, becausefor...of,.map,.filter,.reduce, andArray.from({ length })cover most cases, but it’s a legitimateletwhen the index is genuinely what you need.
That list is exhaustive. Outside of those shapes, if you wrote let and never reassigned, change it to const. You won’t have to enforce this by hand: Biome’s useConst rule, already enabled in the chapter’s biome.json from Unit 1, catches the pattern at build time and tells you exactly which let to convert. The habit is still worth building as a reading skill, because a PR review happens before the lint has run. When you see a let, you want to immediately ask where it gets reassigned.
Here is the same accumulator in three forms: the form to leave behind, the form to reach for occasionally, and the form an experienced engineer reaches for first.
var total = 0;for (var i = 0; i < amounts.length; i++) { total = total + amounts[i];}var is the legacy form: function-scoped instead of block-scoped, hoisted with an initial undefined, and the source of an entire generation of subtle scope bugs. The course never writes it. Recognize it in old code and convert it to let or const.
let total = 0;for (const amount of amounts) { total = total + amount;}This is the acceptable manual form when .reduce doesn’t fit cleanly: a side effect inside the loop, a branch that feeds several accumulators, a structure that needs the imperative shape. Notice the inner binding is const even though its value changes per iteration. for...of creates a fresh binding scoped to the loop body on each pass, so the binding is never reassigned within a single iteration.
const total = amounts.reduce((sum, amount) => sum + amount, 0);There’s no mutable binding, no manual loop, and no off-by-one index to get wrong. When the shape fits, meaning a single value derived from walking a list, .reduce is the form an experienced engineer reaches for first, and the binding is const again because nothing needs to reassign.
The pattern across the three tabs is the chapter’s broader thesis showing up at the keyword level. The default reach is the most constrained form: const plus a method that doesn’t need mutation. The acceptable fallback is the next step up, let plus a manual loop. The legacy form, var, belongs in code you’re rewriting, never in code you’re authoring. Apply that same ordering to every binding decision in the course and reviewing your own code becomes far less work.
Block scope and the Temporal Dead Zone
Section titled “Block scope and the Temporal Dead Zone”Two mechanical rules sit underneath const and let, and they’re linked tightly enough that an experienced engineer thinks of them together. The first is block scope: every const and let binding is scoped to the nearest pair of curly braces enclosing it, whether that’s a function body, an if branch, a for loop, an arrow-function body, or a bare { ... } block expression. The scope is lexical, meaning it’s determined by where the binding is written, not by the call stack that reaches it at runtime.
if (user.isAdmin) { const tier = 'pro'; console.log(tier);}
// console.log(tier); // ReferenceError — tier is not visible out hereThat’s the whole rule: the binding lives inside its block and nowhere else. var is the exception that broke this. Because var is function-scoped, a var declared inside an if block leaks out to the surrounding function, which is the legacy bug class block scope was designed to eliminate.
The second rule stops a different bug class. Every const and let binding enters a state called the Temporal Dead Zone (TDZ for short) the moment its scope starts, and stays there until the line that declares it runs. While a binding is in the TDZ, the name exists, since the parser has already seen the declaration, but accessing it throws a ReferenceError rather than returning a value.
The TDZ closes an old var hazard. With var, accessing the binding before the declaration line silently returned undefined, because the engine hoisted the name and initialized it to undefined before any code ran. So a typo or a wrongly ordered statement gave you undefined at runtime instead of an error, and that undefined then propagated through everything until it surfaced somewhere unrelated. The TDZ makes the same access fail at the moment the bad code runs, which is the cheapest moment to fix it.
Run the short program below. You don’t need to write anything; press Run and watch the access fail.
Run the snippet as-is. The first line accesses `x` before its `const` declaration on the next line; the engine refuses the access. The test asserts the access throws a ReferenceError — that error is the lesson.
Watching the error happen makes the concept stick better than reading about it. The fix isn’t to swap to var to make the error go away; the fix is to declare the binding before you use it. That points to a habit worth building now: declare bindings at the top of the scope where they’re used. With that in place, anyone scanning a function top-down sees every declaration before its first use, and you almost never have to think about the TDZ again.
A brief word on hoisting , the name for the mechanism underneath all of this. In 2026 JavaScript, const, let, and class hoist their name but not their value: the binding exists from the top of the scope but is unreachable until the declaration line runs, which is the TDZ you just met. var hoists its name and pre-initializes it to undefined, which is the bug the TDZ was designed to close. Function declarations are the third form: they hoist their entire body, which is why you can call a function foo() {} declared at the bottom of a file from the top. None of this comes up in day-to-day 2026 code. Declare bindings before you use them and the whole mechanism stays invisible.
Object.freeze, readonly, and why the course skips runtime freezing
Section titled “Object.freeze, readonly, and why the course skips runtime freezing”Object.freeze is the standard-library tool that does what const doesn’t: it makes an object’s top-level properties read-only at runtime. Once frozen, any attempt to write to a property either silently no-ops (in non-strict mode, which the course never runs in) or throws TypeError (in strict mode, which all modules and class bodies use by default). The freeze is shallow, just like the spread copies from the first lesson, so nested objects stay mutable.
const config = Object.freeze({ feature: true, env: { tier: 'pro' } });
// config.feature = false; // TypeError in strict mode — top-level write rejected.config.env.tier = 'free'; // allowed — the freeze is shallow.The course names Object.freeze so you recognize it in the wild, and then doesn’t reach for it. The reason is straightforward: TypeScript’s readonly modifier and as const, both covered properly in Chapter 4, “Typing values you know,” give the same “this can’t be reassigned” guarantee at compile time, with zero runtime cost. The bug becomes a red squiggle in your editor before you ever run the code, instead of a TypeError that surfaces in production. Object.freeze is the right tool for the rare case where a runtime guarantee is genuinely required, usually a module exporting a config object that third-party code receives and could tamper with. Inside a SaaS app where you control both ends of every call, the typing tools are the better choice.
The reach order, then, is readonly and as const for compile-time immutability, and Object.freeze only when a runtime guarantee is genuinely required, which inside an app you own end-to-end is almost never.
const doesn’t narrow types
Section titled “const doesn’t narrow types”One last value-model observation before the exercise, because it sets up a habit you’ll need the moment TypeScript shows up in earnest. const on a primitive narrows the inferred type to the literal value. const on an object widens it. That asymmetry catches everyone the first time, and the fix is the same as const you just saw named.
Hover the underlined names in the two snippets below to see what TypeScript actually infers.
const greeting = 'hello';On a primitive, const makes TypeScript infer the literal type 'hello', not the wider string. That’s a special case: because const blocks reassignment and the value is a primitive that can’t be mutated, the type checker knows the value will never be anything other than 'hello' for the lifetime of the binding.
const config = { feature: true };On an object, const does not carry the narrowing through to the properties. TypeScript infers config.feature as boolean, not true, because the object is mutable: someone could write config.feature = false later, which const doesn’t prevent, as you just saw. The type checker widens conservatively to reflect that reality.
The fix when you actually want the literal types is as const, which you’ll meet properly in Chapter 4, “Typing values you know.” Here is a one-line preview so you recognize the shape:
const config = { feature: true } as const;// inferred type: { readonly feature: true }Two things changed: every property is readonly (the compile-time Object.freeze), and every primitive value narrows to its literal type. The chapter that owns as const goes into how the inference algorithm actually works. What matters here is the trigger: when you write const on an object literal and want TypeScript to lock the property types as well as the binding, as const is the reach. That habit starts at the value-model level, which is why it gets a mention in this chapter.
Check yourself
Section titled “Check yourself”Below are eight real bindings to sort into three buckets. The first two buckets are the easy classifications. The third is for cases where the value looks like it’s changing but the binding doesn’t, so const is the only correct call even though a quick reading might reach for let. The skill to build is recognizing those cases as const without a second thought.
Sort each binding into where you'd reach for const, let, or 'const (looks like let, but isn't)' — the binding doesn't reassign, so const is the right call regardless of how the value moves. Drag each item into the bucket it belongs to, then press Check.
for (...; ...; i++)for...of loop variable.pushThe two third-bucket cases deserve a closer look, because const is the only sensible call in both. A for...of loop variable feels like it’s changing, and the value does change each iteration, but the language creates a brand-new binding scoped to the loop body on every pass, so the name is never reassigned. Write let there and you’re claiming a reassignment that doesn’t exist; Biome’s useConst rule flags it on the spot. An array built with .push mutates the array in place, but the arrow keeps pointing at the same array, so the binding never reassigns either. Both are const. Reach for let only when you’d genuinely write binding = somethingElse somewhere in the body.
External resources
Section titled “External resources”The canonical reference, including the precise reassignment rules and the interaction with destructuring.
The authoritative write-up of the Temporal Dead Zone, with the `typeof` edge case and the contrast against `var` hoisting.
Full reference for the runtime freeze, including a `deepFreeze` recipe for the rare case where the shallow guarantee isn't enough.
The build-time backstop already wired into the chapter project. Catches every `let` that should be a `const` automatically.