RizTech Academy logo
RizTech Academy
Data Structures, TypedLesson 2 of 325 min

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

  1. Mark a property readonly and try to reassign it. Read the error.
  2. Type a readonly number[] and try push and index assignment. Read both errors.
  3. Reproduce the shallow caveat: readonly members: string[], then push to the array (allowed). Fix it with readonly string[].
  4. Apply as const to a nested config and try to mutate a nested property. Confirm it is a deep error.
  5. Do an immutable update of an object with { ...obj, field: new } and confirm the original is unchanged.
  6. Add to, remove from, and transform an array without mutating it ([...arr, x], slice, map). Confirm the original array is unchanged each time.
  7. Note that readonly is erased at runtime: pass a readonly array to code typed as a mutable array (via a cast) and observe nothing stops runtime mutation. Reason about why.

Official documentation

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