Command Palette

Search for a command to run...

Hectal
PHASE 5Beginner ~27 min· topic 1 of 6

Topic 5.1

Readonly Arrays and Immutability

In one line

A readonly T[] is a list you can look at but not change: no push, no assigning to an index. It is a promise the checker enforces, and it is shallow and gone at runtime.

Think of it like this

A library reference book. You can read it, copy a page, and quote it, but the sticker on the cover says "Do not write in this book". If you want to scribble notes, you photocopy a page first. **A readonly array is a reference book: anyone can read it, nobody may change it.** The sticker doesn't stop a determined person with a pen, but it makes sure honest people never do it by accident.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Mutable
Can be changed after it is made. Ordinary arrays are mutable.
Immutable
Cannot be changed after it is made. You make a changed copy instead.
readonly
A TypeScript keyword that removes the ability to change something through that type.
Mutate
To change a value in place, like push adding to the same array.
Shallow
Only the outer layer. readonly on a list doesn't protect the objects inside it.
as const
An assertion that makes a literal read-only and keeps its exact values as the type.
Object.freeze
A JavaScript function that locks an object at runtime so writes fail.
Generic
A type with a blank in angle brackets, like ReadonlyArray<number>. Phase 7 covers them.
Assignable
A value of one type may be put where another type is expected. A number[] is assignable to readonly number[], not the other way round.

Step by step

01The bug: a helper that changes your list

Imagine a function that is supposed to print the highest score. Someone writes it with scores.sort() inside. It works, but it also reorders the caller's array. The next line of the program, which expected scores in the order they were entered, now reads wrong data.

Nothing in the type number[] warns you. The function is allowed to do it.

bug.tswhole filets
function highest(scores: number[]) {
  scores.sort((a, b) => b - a); // changes the caller's array!
  return scores[0];
}

02Add the sticker: readonly

Change the parameter type to readonly number[]. Now scores.sort doesn't exist on the type, so the line above is an error: Property 'sort' does not exist on type 'readonly number[]'.

The fix is to work on a copy. [...scores] (the spread syntax) makes a new array, and you may sort the copy as much as you like. In modern JavaScript, scores.toSorted(...) does the same in one step.

fixed.tswhole filets
function highest(scores: readonly number[]) {
  const sorted = [...scores].sort((a, b) => b - a);
  return sorted[0];
}

03Which direction is allowed

A mutable array goes into a readonly slot with no problem. A readonly array can't go into a mutable slot. Think of it as permissions: you can always hand over *less* power, never *more*.

Which direction is alloweddiagram
Rendering diagram…

04as const and Object.freeze

as const freezes the *type*: the array becomes read-only and its items keep their exact values. Object.freeze freezes the *value* at runtime, and TypeScript's types for it return the readonly version too.

Use as const for lists of fixed options you also want as a type. Use Object.freeze when the runtime protection matters, for example a shared configuration object.

options.tswhole filets
const sizes = ["S", "M", "L"] as const;
type Size = (typeof sizes)[number]; // "S" | "M" | "L"

05Remember: shallow

A readonly list of objects only locks the list. If the objects must not change either, mark their fields readonly too (Topic 3.2 covers readonly members). The checker has no built-in "make everything inside read-only" for a single array; a deep version is built from mapped types and recursion in Topics 8.4 and 9.5.

Try it yourself

  1. 1

    Make the helper safe

    In the second example, change total's parameter from readonly number[] back to number[] and add nums.push(0); inside. Predict whether total(mine) still compiles (yes). Then change the call to total(fixed) and read the error: a readonly array can't go to a mutable parameter (TS2345 in TypeScript 6.0, TS4104 in 7.0, with the same explanation).

    Move the readonly back and the push becomes the error instead. The checker moved the mistake to the line that is actually wrong.

  2. 2

    Spot what survives

    In the first example, delete the three bad lines and add scores.map((n) => n * 2), scores.slice(1) and scores.includes(72). Predict: do these compile? All three do, because none of them changes the array.

  3. 3

    Freeze a nested object

    In the third example, change the frozen object to { pos: { x: 1 }, y: 2 } and write point.pos.x = 5;. Predict whether Object.freeze protects the inner object. It doesn't (it is shallow as well): the assignment compiles, the panel shows pos as a plain { x: number } with no readonly, and at runtime the write succeeds.

Code & diagrams

A readonly array: reading works, changing is an error

Three changes are rejected: push, writing to an index, and changing length. The code still runs because types are erased, so the console shows the array really was changed. toSorted returns a new array and is allowed.

Console

(nothing was printed)

Expected console output

[ 60, 72, 85, 100 ] [ 100, 72, 85, 60 ]
Mutable into readonly is fine, the other way is not

total accepts a normal array because it promises not to change it. The line that copies a readonly array into a number[] variable is rejected. ReadonlyArray<number> is the same type as readonly number[].

Console

(nothing was printed)

Expected console output

6
3
as const and Object.freeze

as const keeps the three exact strings, so Role is a union of them and "owner" is rejected. Object.freeze really locks the object: the write is a type error and also throws at runtime, because modules run in strict mode.

Console

(nothing was printed)

Expected console output

Cannot assign to read only property 'x' of object '#<Object>'
admin { x: 1, y: 2 } [ 3, 1, 2 ]
Readonly is shallow

The list is locked but the objects inside are not, so changing a score is allowed and push is not. With readonly fields on the object type, the score change is rejected too. The ! after team[0] is explained in Topic 5.6: with noUncheckedIndexedAccess, an index read might be undefined.

Console

(nothing was printed)

Expected console output

{ name: 'Asha', score: 99 }

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget as const

In the third example, delete as const from the first line (const roles = ["admin", "editor", "viewer"];) and add console.log(bad); at the end.

terminal
$ Types panel
── what you'll see ──
index.ts(1,7): const roles: string[]
(and the error on line 5, `const bad: Role = "owner"`, is gone; the console now also prints `owner`)

Break #2

Pass a readonly array to a mutable slot

Copy a readonly number[] into a plain number[] variable: const mutable: number[] = fixed;.

terminal
$ Types panel
── what you'll see ──
index.ts(8,7): error TS4104: The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.

Myth vs fact

Myth

A readonly array can never change.

Fact

readonly is a compile-time rule only. The array is an ordinary JavaScript array at runtime, so a cast, an any, or untyped JavaScript can still mutate it. Use Object.freeze when you need a runtime lock.

Myth

readonly makes everything inside the array read-only as well.

Fact

It is shallow. Objects inside a readonly Player[] can still have their fields changed unless those fields are readonly too.

Myth

readonly number[] is slower or a different kind of array.

Fact

It is the same array at runtime. The keyword exists only in the type system and is erased with the rest of the types.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    readonly T[] is a supertype of T[] (every mutable array is usable as a readonly one), which is why it is the right type for read-only parameters: it widens what callers may pass. Note that this is slightly unsound in the other direction, see the next point.

  • ▸

    TypeScript's readonly is not enforced through aliasing. If you hold a readonly number[] and someone else holds a number[] pointing at the same array, they can change what you thought was stable. The checker does not track who else can reach an object; for that you need real immutable data structures or Object.freeze.

  • ▸

    as const on an array gives a readonly tuple, not a readonly array. That is why roles[0] is "admin" rather than string, and why (typeof roles)[number] yields the union of elements. It also means roles.push doesn't exist, and a function parameter typed string[] rejects it (TS2345 in TypeScript 6.0, TS4104 in 7.0; a plain assignment gives TS4104 in both); type such parameters readonly string[].

  • ▸

    ES2023 added toSorted, toReversed, toSpliced and with, which return changed copies. The lib.es2023.array.d.ts types declare them on both T[] and ReadonlyArray<T>, which makes immutable-style code natural. They need a lib/target of ES2023 or later and a runtime that has them (Node 20+, current browsers).

Remember this

  1. 1

    The problem this solves: A function that receives an array can change it, and the caller's list changes without warning. Marking it read-only makes that an error. An ordinary array type, number[], allows reading and changing: push, pop, sort, nums[0] = 9, even nums.length = 0 to empty it. When you pass an array to a function, that function can change *your* array behind your back. This is a very common source of bugs: you hand a list to a helper to sum it, and the helper sorts it in place, so your list is now in a different order.

  2. 2

    Writing readonly before the array type, readonly number[], removes every changing method from the type: push, pop, shift, unshift, splice, sort, reverse, fill and copyWithin disappear, and assigning to nums[0] or nums.length becomes an error. Reading methods (map, filter, slice, reduce, at, includes, length, for...of) all still work, and so do the copy-returning methods toSorted, toReversed and toSpliced.

  3. 3

    readonly number[] and ReadonlyArray<number> are two spellings of the same type: the angle brackets are a generic (a type with a blank to fill in, Phase 7). The readonly modifier is shorter and is what editors print. The same idea exists for other collections: ReadonlyMap<K, V> and ReadonlySet<T> have no set, delete, add or clear.

  4. 4

    Assignable means "a value of this type may be put where that type is expected" (into a variable or a parameter). Assignability goes one way here. A mutable number[] can be passed where a readonly number[] is expected (promising not to change something you are allowed to change is harmless). A readonly number[] cannot be assigned to number[] (error TS4104; when it is a function argument, TypeScript 6.0 prints TS2345 with the same explanation underneath and 7.0 prints TS4104), because the receiver might change what you promised was untouched. So the rule for functions is: **if a function only reads its array parameter, type it as readonly T[]**. Callers can pass anything, and the function can't sneak in a mutation.

  5. 5

    as const on an array literal does two things at once: it makes the array readonly, and it keeps each element's exact literal type, so ["admin", "editor"] as const is readonly ["admin", "editor"], a read-only tuple (Topic 5.2) of two exact strings. From it you can build a union of the allowed values with (typeof roles)[number], a pattern you'll use in Topic 5.3 and Phase 8.

  6. 6

    readonly is shallow and compile-time only. Shallow: readonly Player[] stops you changing which players are in the list, but each Player object inside is still ordinary, so team[0].score = 99 is allowed unless score itself is readonly. Compile-time only: at runtime the array is a normal array, so a cast or plain JavaScript can still mutate it. For a real runtime lock use Object.freeze, which TypeScript types as readonly too (Object.freeze([3, 1, 2]) is readonly number[]), and which makes a write throw TypeError in strict mode code such as modules.

Explain it without notes

01

What does readonly number[] mean, and what changes compared with number[]?

02

Why can you pass a number[] to a function that takes readonly number[], but not the other way round?

03

Is readonly a runtime guarantee? What would you use if you need one?

04

What is the difference between readonly string[] and ["a", "b"] as const?

Practice

01

Write sortedCopy(list) that returns a new array of the numbers in ascending order without changing the original. Type the parameter so that calling list.sort() directly inside would be an error.

02

Make a constant weekend containing "Sat" and "Sun" that is read-only, and derive a type Day from it so const d: Day = "Mon" is an error.

03

Declare const nums: readonly number[] = [1, 2, 3] and try to give it to a function that takes number[]. Fix it in two different ways.

Trade-offs

  • ↔

    Typing parameters as readonly T[] makes functions safer and more flexible for callers, but you must spell it out and copy before sorting or reversing. The copy costs a little memory; it is almost never the bottleneck.

  • ↔

    Object.freeze gives a runtime guarantee but only one level deep and a small performance cost on very hot objects. readonly is free but is only a compile-time promise.

Done when you can

  • I can write readonly T[] and ReadonlyArray<T> and know they are the same.

  • I can say which methods disappear and which still work on a readonly array.

  • I can explain why mutable-to-readonly is allowed and readonly-to-mutable is TS4104.

  • I can use as const on an array and derive a union with (typeof arr)[number].

  • I can explain that readonly is shallow and erased at runtime, and name Object.freeze as the runtime tool.