readonly, ReadonlyArray, as const and deep immutability
Data that cannot change under you is data you can reason about. Immutability — never mutating a value,
only producing new versions — is one of the most valuable habits in modern TypeScript (and the backbone
of React and Redux). TypeScript gives you compile-time tools to enforce it: readonly, ReadonlyArray,
as const, and deep-immutability patterns. This lesson gathers them and, importantly, is honest about
what they do and do not guarantee.
Why immutability
When a value can be mutated by any code that holds a reference to it, bugs hide in "who changed this?".
When a value is immutable — you make a new version instead of changing it — that whole class of bug
disappears, and code becomes easier to follow: a value is what it was set to, full stop. This is why
React state is immutable (you replace it, never mutate it), why Redux reducers return new state, and why
functional style favours transformations over mutations. TypeScript's readonly tools let you make this
a compile-enforced rule rather than a hope.
readonly properties and ReadonlyArray
You met both earlier; here they are as the immutability toolkit:
interface User {
readonly id: string; // set once, never reassigned
name: string;
}
const prices: readonly number[] = [120, 340, 90]; // ReadonlyArray<number>
prices.push(500); // error: Property 'push' does not exist on type 'readonly number[]'
prices[0] = 999; // error: cannot assign to a readonly index
A readonly property cannot be reassigned; a readonly number[] (or ReadonlyArray<number>) has no
mutating methods (push, pop, sort, splice) and no index assignment — only reading. Declaring a
function parameter as readonly is a promise to the caller that you will not modify their data, which
the compiler enforces:
function total(prices: readonly number[]): number {
return prices.reduce((a, b) => a + b, 0); // reading only — fine
// prices.push(0) would be a compile error — the type forbids it
}
This is a habit worth adopting: type parameters you only read as readonly. It documents intent and
prevents accidental mutation of a caller's data.
as const — deep readonly literals
as const (from the literal-types lesson) is the most powerful immutability tool for values: it makes
a value deeply readonly and infers the narrowest literal types:
const config = {
api: { url: "https://api.example.com", timeout: 30 },
retries: 3,
} as const;
// every property, at every level, is readonly; types are exact literals
config.retries = 5; // error: readonly
config.api.url = "other"; // error: readonly even though it is NESTED — as const is DEEP
Unlike a single readonly (which is shallow), as const freezes the whole structure — nested objects and
arrays too. This is the tool for a genuinely constant configuration, a fixed lookup table, or any value
that must never change. It also gives you literal types (retries is 3, not number), which is why it
appears in the enum-replacement pattern.
The shallow-readonly caveat — be honest
Here is the crucial honesty, and the reason to understand how these work. readonly is a
compile-time, mostly-shallow check — it is not a runtime freeze.
interface Team {
readonly members: string[]; // the PROPERTY is readonly, but the ARRAY is mutable!
}
const team: Team = { members: ["Kavita"] };
team.members = []; // error — cannot reassign the property (readonly)
team.members.push("Ravi"); // NO error — the array itself is still mutable!
readonly members: string[] stops you reassigning members to a different array, but the array it
points at is a normal string[] — so team.members.push("Ravi") works. To make the contents immutable
too, the type must be readonly members: readonly string[]. This shallow-versus-deep distinction is a
real source of confusion, and the fix is to be deliberate: use readonly T[] (not just readonly prop: T[]) when you want the collection's contents protected, and as const for deep immutability of a
literal.
And the second honesty: these are erased at runtime. readonly and as const are types — they
vanish when the code runs (type erasure, from the how-checking-works lesson). So they stop your
TypeScript code from mutating, but they do not prevent mutation through untyped code, any, or at
runtime via JSON.parsed data. For a true runtime freeze, Object.freeze() is the JavaScript tool
(and TypeScript infers a readonly type from it) — but it is shallow too and has a runtime cost. For most
work, the compile-time readonly/as const is exactly what you want: it catches your mistakes, which
is where the bugs are.
Immutable updates — the pattern you will use
Because you do not mutate, you replace — producing a new value with the change. The spread operator is how, and TypeScript keeps it typed:
interface User { id: string; name: string; city: string; }
const user: User = { id: "1", name: "Kavita", city: "Pune" };
// "change" the city by making a new object — the original is untouched
const moved: User = { ...user, city: "Mumbai" };
// moved is { id: "1", name: "Kavita", city: "Mumbai" }; user is unchanged
// arrays: produce a new array instead of mutating
const prices = [120, 340];
const withMore = [...prices, 500]; // add — new array
const doubled = prices.map(p => p * 2); // transform — new array (map already returns new)
const withoutFirst = prices.slice(1); // remove — new array
{ ...user, city: "Mumbai" } spreads the original and overrides one field — the idiomatic immutable
update, and exactly how you update React state or a Redux store. For arrays, [...arr, x] (add),
.map/.filter (transform/remove), and .slice (subset) all produce new arrays, leaving the original
alone — unlike push/splice/sort which mutate. Prefer the non-mutating operations, and combine
with readonly types to make mutation a compile error, so you cannot slip back into mutating by accident.
Check your work
Why immutability helps. A value that cannot change under you removes the "who changed this?" class of bug and makes code easy to follow.
readonly property and ReadonlyArray. A readonly property cannot be reassigned; a readonly T[]
has no mutating methods — declare read-only parameters readonly.
What as const does that readonly does not. Deep immutability — it freezes nested objects and
arrays, not just the top level — plus literal types.
The shallow-readonly caveat. readonly members: string[] stops reassigning the property but not
mutating the array; use readonly string[] for the contents.
The runtime-erasure caveat. readonly/as const are types, erased at runtime — they catch your
code's mutations, not runtime mutation via any or untyped data; Object.freeze is the runtime tool.
The immutable-update pattern. Replace, do not mutate — { ...user, field: new } for objects;
[...arr, x]/map/filter/slice for arrays.
Which array operations mutate versus not. push/splice/sort mutate; map/filter/slice/spread
produce new arrays — prefer the latter.
Practice
- Mark a property
readonlyand try to reassign it. Read the error. - Type a
readonly number[]and trypushand index assignment. Read both errors. - Reproduce the shallow caveat:
readonly members: string[], thenpushto the array (allowed). Fix it withreadonly string[]. - Apply
as constto a nested config and try to mutate a nested property. Confirm it is a deep error. - Do an immutable update of an object with
{ ...obj, field: new }and confirm the original is unchanged. - Add to, remove from, and transform an array without mutating it (
[...arr, x],slice,map). Confirm the original array is unchanged each time. - Note that
readonlyis erased at runtime: pass areadonlyarray to code typed as a mutable array (via a cast) and observe nothing stops runtime mutation. Reason about why.
Official documentation
- TypeScript — readonly Properties — And its shallow nature.
- TypeScript — ReadonlyArray and const assertions — Immutable arrays and
as const. - MDN — Object.freeze — The runtime freeze.
- Redux — Immutable update patterns — Where these patterns are used most.
Next: typing iterables, iterators and generators.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship