Ch. 2 · TypeScript

TypeScript readonly Arrays, ReadonlyMap and ReadonlySet

What readonly arrays, ReadonlyMap and ReadonlySet block, why readonly is a view rather than a frozen copy, and how to share state safely.

~5 min readintermediateupdated Oct 7, 2026

readonly on a collection is one of the cheapest ways to make an API say what it does. A function that takes readonly LineItem[] promises not to rearrange your cart. A class that returns readonly LineItem[] asks you not to edit its internals. But readonly is a property of the type you’re looking through, not of the array, and that difference is what interview questions about it are really testing.

What readonly removes from an array

Take a function that’s supposed to only read a list of line items:

type LineItem = { sku: string; qty: number };

function total(items: readonly LineItem[]): number {
  items.push({ sku: "free", qty: 1 });
  items.sort((a, b) => a.qty - b.qty);
  return items.reduce((sum, i) => sum + i.qty, 0);
}
TypeScript
ro1.ts(4,9): error TS2339: Property 'push' does not exist on type 'readonly LineItem[]'.
ro1.ts(5,9): error TS2339: Property 'sort' does not exist on type 'readonly LineItem[]'.
Text

readonly LineItem[] (also written ReadonlyArray<LineItem>) is a different interface from LineItem[]. It has map, filter, reduce, slice, indexing for reads, and every other non-mutating method. It doesn’t have push, pop, splice, sort, reverse, fill or index assignment. So the mutations inside total fail to compile, and the reader of the signature knows the cart is safe to pass.

The sort error deserves a moment because it catches people. Array.prototype.sort sorts in place and returns the same array. Plenty of code writes const sorted = items.sort(...) thinking it made a copy, and then the caller’s list is reordered too. With a readonly parameter, the compiler forces the question. The non-mutating alternative is toSorted, added in ES2023:

function sortedByQty(items: readonly LineItem[]): LineItem[] {
  return items.toSorted((a, b) => a.qty - b.qty);
}
TypeScript

That needs lib set to es2023 or later (and a runtime that supports it, such as Node 20+). On older targets, [...items].sort(...) does the same.

Maps and sets have equivalents. ReadonlyMap<K, V> has get, has and iteration but no set or delete, and ReadonlySet<T> has no add:

ro1.ts(31,6): error TS2339: Property 'add' does not exist on type 'ReadonlySet<string>'.
ro1.ts(33,8): error TS2339: Property 'set' does not exist on type 'ReadonlyMap<string, number>'.
Text

Which way assignment works

A mutable array can be passed where a readonly one is expected. That’s what makes readonly parameters free to adopt: every existing caller still compiles. The reverse is blocked:

function legacySort(items: LineItem[]) { items.sort((a, b) => a.qty - b.qty); }
declare const cart: readonly LineItem[];
legacySort(cart);
TypeScript
ro1.ts(15,12): error TS4104: The type 'readonly LineItem[]' is 'readonly' and cannot be assigned to the mutable type 'LineItem[]'.
Text

legacySort might mutate, so handing it a readonly array would break the promise. This is the error you’ll hit when you start adding readonly to one layer of a codebase and the layer below still takes mutable arrays. The fix is to propagate readonly downward through functions that don’t mutate, or to pass a copy (legacySort([...cart])) where mutation is intended.

The same rule explains a guideline worth stating in an interview: type parameters as readonly T[] whenever the function only reads. It documents intent, catches accidental mutation in the body, and accepts both mutable and readonly arguments, so it’s strictly more flexible for callers.

Readonly is shallow

The elements aren’t protected:

function shallow(items: readonly LineItem[]) {
  items[0].qty = 999; // compiles
}
TypeScript

The array’s slots are readonly, but each slot holds an ordinary mutable LineItem object. To protect one level further, make the element type readonly too: readonly Readonly<LineItem>[]. Then items[0].qty = 1 fails with Cannot assign to 'qty' because it is a read-only property. For deeply nested data you’d need a recursive DeepReadonly type, and at that point it’s worth asking whether the data should be immutable by construction instead, such as being produced fresh by a reducer.

A view, not a snapshot

Here’s the part that separates understanding from memorisation. A class exposes its items through a readonly getter:

class Cart {
  #items: LineItem[] = [];
  get items(): readonly LineItem[] { return this.#items; }
  add(item: LineItem) { this.#items.push(item); }
}

const cart = new Cart();
cart.add({ sku: "book", qty: 1 });
const snapshot = cart.items;
cart.add({ sku: "pen", qty: 2 });
console.log(snapshot.length); // 2

(cart.items as LineItem[]).length = 0;
console.log(cart.items.length); // 0
TypeScript

Those numbers are from running the compiled code. snapshot isn’t a snapshot. It’s the same array object as #items, seen through a type with fewer methods. When the cart adds an item, snapshot sees it. If a React component stored cart.items and compared it with the previous value to decide whether to re-render, the comparison would say nothing changed, because it’s the same reference.

And because readonly is only a type, a cast removes it. The second half of the example empties the cart’s private state from outside. Nobody writes that on purpose, but a JavaScript caller, a library with loose types or a hurried as any can.

So readonly expresses an agreement between cooperating TypeScript code. It’s excellent for that, but it isn’t encapsulation.

Handing out state safely

When ownership matters, combine the type with a runtime decision. The simplest option is to return a copy:

class SafeCart {
  #items: LineItem[] = [];
  get items(): readonly Readonly<LineItem>[] { return [...this.#items]; }
  add(item: LineItem) { this.#items.push({ ...item }); }
}
TypeScript

Run the same sequence against SafeCart and the stored value keeps its length of 1 while the cart grows to 2. Each read gets a new array, so callers can’t affect the cart and the cart can’t surprise them. add copies the incoming item too, so the caller can’t change it after handing it over. The cost is an allocation per read, which is negligible for a cart and worth measuring for a list of 100,000 rows.

The alternatives are Object.freeze for runtime enforcement (shallow, like readonly), or an immutable update style where the owner never mutates and every change produces a new array. The last option fits React and Redux well: if #items is replaced on every add rather than pushed into, handing out the current array is safe, because nobody ever modifies it, and reference comparisons become meaningful.

Interview practice

A shared useSelectedRows() hook returns readonly Row[] from a store. A teammate’s component calls rows.sort(byName) and gets a compile error, so they change it to (rows as Row[]).sort(byName). The table elsewhere on the page starts showing rows in the wrong order. Explain what happened and what the component should do instead.

Answer

The store returns its internal array typed as readonly. The compile error was correct: sort mutates in place, and the array belongs to the store. The cast told TypeScript to ignore that, so sort reordered the store’s own array. Every other component reading the same array, including the table, saw the new order, and because the reference didn’t change, change detection based on reference equality may not have re-rendered them consistently either.

The component should sort a copy: rows.toSorted(byName) on ES2023 targets or [...rows].sort(byName) elsewhere, ideally inside useMemo keyed on rows so it doesn’t re-sort on every render. I’d also suggest removing the ability to cast it away in review, for example with a lint rule against as on store values, and, if the store is used widely, having it update immutably so that even a mistaken mutation can’t corrupt state that other readers depend on.

Further reading

More in TypeScript

read ✓TypeScript · mid

TypeScript Mapped Types and Modifiers

How { [K in keyof T]: T[K] } rebuilds a type, how ?, readonly, -? and -readonly change it, and why Partial stops one level down.

~5 min readread →
read ✓TypeScript · mid

TypeScript Tuples for Fixed Position Data

When a fixed-length array beats an object, why a returned [value, setter] pair needs a type or as const, and the push hole in tuples.

~5 min readread →
esc