Before you begin
Why this matters
Many bugs come from a value that someone believed was constant. Once you separate “can this name refer to something else?” from “can this value change?”, you can predict which lines the compiler rejects and which changes reach every name.
What this helps you reason about
- Predict whether a line changes a const name or the value it refers to.
- Keep an array or object from changing, and know which ways of doing it work only while the code is checked.
- Spot a “constant” array that still changes, such as one inside a protected object.
A const name can never be pointed at a new value. The array or object it refers to can still change. This lesson separates those two kinds of change and shows how to protect the value itself.
Already know this? The short version
- const forbids rebinding: assigning to the name is a compile error. It does not forbid mutation: push, element assignment, and property assignment still change the array or object.
- readonly, ReadonlyArray, and Readonly<T> act on one level and as const on every level, but all four are compile-time checks that disappear from the JavaScript that runs; Object.freeze refuses changes while the program runs, one level deep.
- To get a changed version without changing the original, change a copy: [...songs] and { ...show } copy one level, and structuredClone copies every level.
- Every object or array stored inside a frozen object can still change.
Words in this lesson (6)
- variable
- A name that holds a value. let and const declare variables; a let variable can be given a new value later, and a const variable cannot.
- reference
- A value that leads to an object. A variable whose value is an object holds a reference, not a copy of the object, so copying the variable gives a second way to reach the same object.
- rebinding
- Making a name refer to a different value, as an assignment does. It changes what the name refers to, not the value it referred to before.
- mutation
- Changing the inside of an object, as in counter.value = 5. Every name that refers to that object sees the change. Reassignment (count = 5) is different: it changes only which value one name holds.
- readonly
- A TypeScript marker on a property or an array type that makes the compiler reject changes through that type. It is checked before the program runs and has no effect while it runs.
- Object.freeze
- A function that makes an object or array refuse changes to its own properties or elements while the program runs. Objects stored inside it are not frozen.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Two changes
Which two things can change?
Two different things can change in a program. Rebinding makes a name refer to a different value, as in songs = otherSongs. Mutation changes the value itself: songs.push("finale") adds an element at the end of the array that songs refers to.
Assignment and reassignment followed rebinding, and Variables showed that an array variable holds a reference, so a change through one name shows through every name. This lesson asks which of the two changes a constant name forbids.
const forbids rebinding. It does not forbid mutation. Below, push changes the array, while the assignment in the comment would not compile.
const songs = ["overture", "duet"];
songs.push("finale"); // mutation: allowed
console.log(songs.length); // 3
// songs = []; // rebinding: a compile error Rebinding changes what a name refers to; mutation changes the value itself.
Coming from C++: A const array in TypeScript is not like a const std::vector: its elements can still change.
Experiment
Add a song to a const array
push changes the array; it does not touch the name songs. const forbids a new assignment to the name, so the first line 2 is allowed and the edited one is not.
Why it matters A const name tells you which array you have, not what is in it.
Predict whether the compiler accepts line 2, and what is printed. Then change line 2 to songs = ["overture", "duet", "finale"]; and predict again.
const songs = ["overture", "duet"];
songs.push("finale");
console.log(songs.length);
What to look for
At the stop on line 2, songs shows ["overture", "duet"]; at the stops on line 3 (the stepper stops there twice) it shows ["overture", "duet", "finale"], and the run prints 3. After the edit, the compiler reports Cannot assign to 'songs' because it is a constant., pointing at line 2, and nothing runs.
Expected output
302 / Constant names
What does a constant name forbid?
Assigning to a const name is a compile error: Cannot assign to 'maxSongs' because it is a constant. A let name can be assigned again.
Use const for every name that is never assigned again. Then a reader knows that the name refers to the same value on every line. That value can still change by mutation, so const says nothing about the contents.
For a number, a string, or a boolean, const is enough. These values cannot be changed at all: every operation on them makes a new value. So a const number is fixed for good.
const maxSongs = 12;
let encore = "finale";
encore = "reprise"; // allowed: let
// maxSongs = 13; // a compile error const fixes which value a name refers to; it does not say whether that value can change.
03 / Fixed values
How do you stop the value itself from changing?
TypeScript has two kinds of tools for protecting a value, and they work at different times.
readonly works while the code is checked. readonly string[], also written ReadonlyArray<string>, is an array type without push or element assignment, so the compiler rejects a change through that type. Readonly<Show> makes every property of a type Show readonly. as const after an array or object literal makes every part of the literal readonly. These are types only; they disappear from the JavaScript that runs.
Object.freeze works while the program runs. Object.freeze(songs) makes the array refuse changes to its elements, and TypeScript also gives the result a readonly type.
When you need a changed version, change a copy. [...songs] is a new array with the same elements, and { ...show } is a new object with the same properties. Both copy one level. structuredClone(show) copies every level, so the arrays and objects inside the copy are new too.
const songs = Object.freeze(["overture", "duet"]);
const programme = [...songs]; // a new array
programme.push("finale");
console.log(songs.length, programme.length); // 2 3 Freeze a value, or type it as readonly, to stop changes; change a copy when you need a changed version.
Coming from React: Readonly<Props> is one level deep too: an array inside the props can still be changed with push. Copy it with [...items] before you change it.
Precisely: what happens to a write to a frozen object
In strict mode code, which includes every module and class body, writing to a frozen property throws a TypeError. Outside strict mode the write is ignored without an error. Either way the value does not change. TypeScript usually rejects the write before the program runs, through the readonly type that Object.freeze returns.
Experiment
Freeze an array and change a copy
Object.freeze returns the same array, frozen, and TypeScript gives it a readonly type, so the compiler rejects push on songs. [...songs] builds a new array that is not frozen, so programme can change.
Why it matters Freeze the value you want to keep, and change a copy.
Predict both printed numbers. Then change programme.push("finale"); to songs.push("finale"); and predict whether it compiles.
const songs = Object.freeze(["overture", "duet"]);
const programme = [...songs];
programme.push("finale");
console.log(songs.length, programme.length);
What to look for
At the stop on line 3, songs and programme both show ["overture", "duet"]: two arrays with the same elements. At the stop on line 4, only programme shows "finale", and the run prints 2 3. After the edit, the compiler reports Property 'push' does not exist on type 'readonly string[]'., pointing at line 3.
Expected output
2 304 / Traps
Where can a “constant” still change?
Object.freeze is shallow: it freezes one level. An array or object stored inside a frozen object is a separate object, and it is not frozen. So show.songs.push("finale") below changes the songs of a frozen show, and the compiler accepts it.
readonly and Readonly<Show> have the same one-level limit. A readonly property cannot be assigned, but the array it refers to is readonly only if its own type says so.
as const is different: it is deep. Every array and object inside the literal is readonly too, at every level, but only while the code is checked. Object.freeze is shallow, and it works while the program runs.
To keep nested values fixed, protect each level that must not change.
const show = Object.freeze({
title: "Moonrise",
songs: ["overture", "duet"],
});
show.songs.push("finale"); // allowed: songs is not frozen
console.log(show.songs.length); // 3
// show.title = "Sunset"; // a compile error Object.freeze and readonly act on one level; the arrays and objects inside can still change.
Coming from Python: A tuple in Python cannot change, but a list inside it can; Object.freeze has the same limit.
Experiment
Add a song inside a frozen object
Object.freeze froze the show object: its title and its songs property cannot be replaced. The array that songs refers to is a separate object, and it was not frozen, so push changes it.
Why it matters A frozen object can still hold arrays and objects that change. Freeze each level that must stay fixed.
Predict whether line 5 compiles and what is printed. Then change line 5 to show.title = "Sunset"; and predict again.
const show = Object.freeze({
title: "Moonrise",
songs: ["overture", "duet"],
});
show.songs.push("finale");
console.log(show.songs.length);
What to look for
At the stops on line 5, show is {title: "Moonrise", songs: ["overture", "duet"]}. At the stops on line 6, the songs inside it show three elements, and the run prints 3. After the edit, the compiler reports Cannot assign to 'title' because it is a read-only property., pointing at line 5.
Expected output
305 / Try it
Can you explain a fresh example?
Follow each name, and decide for each line whether it rebinds a name or changes a value.
const cast = ["Ana", "Ben"];
const backup = cast;
const copy = [...cast];
backup.push("Cleo");
console.log(cast.length, copy.length);
What is printed?
Reveal the reasoning
3 2. backup = cast copies the reference, so backup and cast refer to one array, and push through backup changes it. copy is a new array made before the push, so it keeps two names. No line rebinds a const name; push is a mutation, which const allows.
Practice tickets
- CONST-101 · A table that picks no song starts with another table’s pick → Free
- CONST-102 · Evening performances after a matinee are listed five minutes short → Sign in · Pro
- CONST-103 · Setting up a scene changes the lighting preset → Sign in · Pro