const, let, scope, and the TDZ
How JavaScript binds names: const versus let, block scope, and the Temporal Dead Zone.
Picture this scenario: a teammate ships const config = { feature: true } at the top of a module and assumes the value is now protected. Another module imports config, sets config.feature = false mid-request, and the user-facing flag flips silently for everyone on that request. const did nothing wrong. It never promised to freeze the value, only that the name would keep pointing at the same object.
The opposite mistake costs just as much: a binding declared let “just in case” and never reassigned, until an unrelated change finally reassigns it and breaks the code downstream that read the original value once and assumed it would hold. Both bugs come from misreading what const promises.
const locks the binding, not the value
Section titled “const locks the binding, not the value”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 that binding points at can be mutated. const makes only the first promise, not the second.
As the diagram shows, the binding is a name pointing at a value. const makes that link permanent: you can’t repoint the name at something else, and that is all “no reassignment” means. The value on the other end is a separate question, one const has no opinion about. If that value 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 while the binding stays fixed.
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 mutates the existing object through the binding, changing one property, and const has nothing to say about that. The third line, uncommented, would throw TypeError: Assignment to constant variable. because it reassigns, pointing the name at a different object, which is exactly what const blocks.
For a primitive the distinction collapses, because a primitive has no inside to change: there is no way to mutate 42 or 'hello'. So const x = 42 behaves as if the value were frozen, only because the value can’t be mutated at all. That is where the bugs come from: the model that works for const PORT = 3000 quietly fails on const config = { feature: true }.
The defense isn’t Object.freeze, and this lesson gets to why later. It is the immutability habit from the first lesson of this chapter, copy-then-modify with spread or structuredClone, backed by TypeScript’s compile-time guards. For now, hold onto the rule: the binding is locked, the value is not.
const by default, let when the binding must change
Section titled “const by default, let when the binding must change”Write const for every binding unless the binding has to be reassigned. The trigger is not that the value might change, since const says nothing about the value. The trigger is the binding itself needing to point at something different later. That is the only reason to reach for let.
In practice, let earns its place in a small set of cases:
- An accumulator in a manual loop, like
let total = 0;incremented as you walk a list. The reassignment on each iteration is the point of the loop. When the shape fits, reach for.reducefirst, which avoids the mutable binding entirely. - A branch-assigned value too gnarly for a ternary, where each branch sets the same binding. This is usually a sign the branches should move into a helper that returns the value, leaving the call site
const role = pickRole(...). - A loop counter in a classic
for (let i = 0; ...; i++). It comes up rarely, sincefor...of,.map,.filter,.reduce, andArray.from({ length })cover most cases, but it’s legitimate when the index is what you need.
Outside those shapes, if you wrote let and never reassigned, change it to const. Biome’s useConst rule, enabled in the chapter’s biome.json since Unit 1, JavaScript and TypeScript, catches that at build time and points to the let to convert. Build the habit as a reading skill too: when you see a let, ask where it gets reassigned.
Here is the same accumulator in three forms.
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 a 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 feeding several accumulators, a structure that needs the imperative shape. The inner binding is const even though its value differs per iteration, because for...of creates a fresh binding scoped to the loop body on each pass.
const total = amounts.reduce((sum, amount) => sum + amount, 0);No mutable binding, no manual loop, no off-by-one index to get wrong. When the shape fits, meaning a single value derived from walking a list, reach for .reduce first, and the binding is const again because nothing needs to reassign.
Block scope and the Temporal Dead Zone
Section titled “Block scope and the Temporal Dead Zone”Two mechanical rules sit underneath const and let. The first is block scope: every const and let binding belongs to the nearest pair of curly braces around it, whether that’s a function body, an if branch, a for loop, an arrow-function body, or a bare { ... } block. The scope is lexical: it’s fixed 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 hereThe binding lives inside its block and nowhere else. var is the exception that broke this: it’s function-scoped, so a var declared inside an if block leaks out to the surrounding function. Block scope was designed to eliminate that bug class.
The second rule stops a different one. From the moment its scope starts until the line that declares it runs, every const and let binding sits in the Temporal Dead Zone (TDZ). The name exists, since the parser has already seen the declaration, but accessing it throws a ReferenceError instead of returning a value.
This closes an old var hazard. With var, accessing the binding before its declaration silently returned undefined, so a typo or a misordered statement gave you undefined at runtime instead of an error, and that undefined propagated until it surfaced somewhere unrelated. The TDZ makes the same access fail where the bad code runs, the cheapest place to fix it.
The fix isn’t to reach for var to make the error go away; it’s to declare the binding before you use it. That points to a habit worth building: declare bindings at the top of the scope where they’re used. Then anyone reading a function top-down meets every declaration before its first use, and the TDZ never fires.
The mechanism underneath all of this is hoisting . const, let, and class hoist their name but not their value, which is the TDZ you just met; var hoists its name and pre-initializes it to undefined, the hazard the TDZ closes; a function declaration hoists its whole body, so you can call it from above its definition. Declare bindings before you use them and none of this surfaces.
Object.freeze freezes at runtime; prefer readonly
Section titled “Object.freeze freezes at runtime; prefer readonly”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. Writing to a frozen property throws TypeError in strict mode, which every module and class body uses by default. The freeze is shallow, 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, then doesn’t reach for it. TypeScript’s readonly and as const, both covered in Chapter 4, “Typing values with TypeScript,” give the same “can’t be reassigned” guarantee at compile time with zero runtime cost: the bug becomes a red squiggle in your editor before you run the code, instead of a TypeError in production. Reach for Object.freeze only when a runtime guarantee is genuinely required, such as a config object handed to third-party code that could tamper with it. Inside an app where you control both ends of every call, the typing tools win.
const narrows primitives but widens objects
Section titled “const narrows primitives but widens objects”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.
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.
To lock the property types as well as the binding, reach for as const, covered in Chapter 4:
const config = { feature: true } as const;// inferred type: { readonly feature: true }Two things change: every property becomes readonly (the compile-time Object.freeze), and every primitive value narrows to its literal type.
Check your understanding
Section titled “Check your understanding”Sort each binding into one of three buckets: const, let, or the trickier third case where the value moves but the binding never reassigns, so const is still the only right call.
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 are the ones worth pausing on. A for...of variable feels like it changes, and the value does change each pass, but the language creates a fresh binding for every iteration, so the name is never reassigned: write let and Biome’s useConst rule flags it. An array built with .push mutates in place while the binding keeps pointing at the same array. Both are const. Reach for let only when you’d actually write binding = somethingElse 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.