Skip to content
Chapter 1Lesson 6

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.

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.

Primitive assignment a b 42 42 const a = 42; const b = a; Reference assignment user alias { name: 'Ada', age: 36 } const user = { name: 'Ada', age: 36 }; const alias = user;
Two bindings, one shared object. The arrow is the part = copied.

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 .reduce first, 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, since for...of, .map, .filter, .reduce, and Array.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.

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 here

The 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.

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.

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.

const Binding never changes; default reach
let Binding genuinely must be reassigned
const (looks like let, but isn't) The value moves, the binding doesn't — const is the only right call
A configuration object the file reads once at module top
A loop counter in for (...; ...; i++)
A for...of loop variable
A sum being accumulated across a manual loop
An HTTP response object the handler reads but doesn’t reassign
An array being built incrementally with .push
A request timeout in milliseconds
A function reference imported from another module

The 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.