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
pushadding to the same array. - Shallow
- Only the outer layer.
readonlyon 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 toreadonly 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.
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.
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*.
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.
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
Make the helper safe
In the second example, change
total's parameter fromreadonly number[]back tonumber[]and addnums.push(0);inside. Predict whethertotal(mine)still compiles (yes). Then change the call tototal(fixed)and read the error: a readonly array can't go to a mutable parameter (TS2345in TypeScript 6.0,TS4104in 7.0, with the same explanation).Move the
readonlyback and thepushbecomes the error instead. The checker moved the mistake to the line that is actually wrong. - 2
Spot what survives
In the first example, delete the three bad lines and add
scores.map((n) => n * 2),scores.slice(1)andscores.includes(72). Predict: do these compile? All three do, because none of them changes the array. - 3
Freeze a nested object
In the third example, change the frozen object to
{ pos: { x: 1 }, y: 2 }and writepoint.pos.x = 5;. Predict whetherObject.freezeprotects the inner object. It doesn't (it is shallow as well): the assignment compiles, the panel showsposas a plain{ x: number }with noreadonly, and at runtime the write succeeds.
Code & diagrams
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 ]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
3as 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 ]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.
Break #2
Pass a readonly array to a mutable slot
Copy a readonly number[] into a plain number[] variable: const mutable: number[] = fixed;.
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 ofT[](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
readonlyis not enforced through aliasing. If you hold areadonly number[]and someone else holds anumber[]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 orObject.freeze. - ▸
as conston an array gives a readonly tuple, not a readonly array. That is whyroles[0]is"admin"rather thanstring, and why(typeof roles)[number]yields the union of elements. It also meansroles.pushdoesn't exist, and a function parameter typedstring[]rejects it (TS2345in TypeScript 6.0,TS4104in 7.0; a plain assignment givesTS4104in both); type such parametersreadonly string[]. - ▸
ES2023 added
toSorted,toReversed,toSplicedandwith, which return changed copies. Thelib.es2023.array.d.tstypes declare them on bothT[]andReadonlyArray<T>, which makes immutable-style code natural. They need alib/targetof ES2023 or later and a runtime that has them (Node 20+, current browsers).
Remember this
- 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, evennums.length = 0to 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
Writing
readonlybefore the array type,readonly number[], removes every changing method from the type:push,pop,shift,unshift,splice,sort,reverse,fillandcopyWithindisappear, and assigning tonums[0]ornums.lengthbecomes an error. Reading methods (map,filter,slice,reduce,at,includes,length,for...of) all still work, and so do the copy-returning methodstoSorted,toReversedandtoSpliced. - 3
readonly number[]andReadonlyArray<number>are two spellings of the same type: the angle brackets are a generic (a type with a blank to fill in, Phase 7). Thereadonlymodifier is shorter and is what editors print. The same idea exists for other collections:ReadonlyMap<K, V>andReadonlySet<T>have noset,delete,addorclear. - 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 areadonly number[]is expected (promising not to change something you are allowed to change is harmless). Areadonly number[]cannot be assigned tonumber[](errorTS4104; when it is a function argument, TypeScript 6.0 printsTS2345with the same explanation underneath and 7.0 printsTS4104), 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 asreadonly T[]**. Callers can pass anything, and the function can't sneak in a mutation. - 5
as conston an array literal does two things at once: it makes the arrayreadonly, and it keeps each element's exact literal type, so["admin", "editor"] as constisreadonly ["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
readonlyis shallow and compile-time only. Shallow:readonly Player[]stops you changing which players are in the list, but eachPlayerobject inside is still ordinary, soteam[0].score = 99is allowed unlessscoreitself isreadonly. 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 useObject.freeze, which TypeScript types as readonly too (Object.freeze([3, 1, 2])isreadonly number[]), and which makes a write throwTypeErrorin strict mode code such as modules.
Explain it without notes
What does readonly number[] mean, and what changes compared with number[]?
Why can you pass a number[] to a function that takes readonly number[], but not the other way round?
Is readonly a runtime guarantee? What would you use if you need one?
What is the difference between readonly string[] and ["a", "b"] as const?
Practice
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.
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.
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.freezegives a runtime guarantee but only one level deep and a small performance cost on very hot objects.readonlyis free but is only a compile-time promise.
Done when you can
I can write
readonly T[]andReadonlyArray<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 conston an array and derive a union with(typeof arr)[number].I can explain that
readonlyis shallow and erased at runtime, and nameObject.freezeas the runtime tool.