skip to content

You change a function's parameter annotation in TypeScript from `items: Item[]` to `items: readonly Item[]`. What does that annotation actually guarantee, and what does it not?

level: seniorimportance: should knowfreq 42%

answer

  1. a promise by the callee only
  2. one level deep, no further
  3. nothing survives compilation
  4. the caller still holds the same array
  5. copying is the price of interop

basics

~20 s

It guarantees only that the function body cannot call mutating array methods or assign to indices, and only while type checking. It is shallow, so element objects stay mutable; it is erased at runtime; and the caller still holds a mutable reference to the same array.

solid answer

~40 s

Inside the body, `readonly Item[]` removes `push`, `splice`, `sort`, `reverse` and index assignment, so accidental in-place mutation of the caller's array becomes a compile error — that is the whole of the guarantee. Three things it does not do. It is shallow: `item.name = 'x'` on an element still compiles, because only the array's own slots are protected. It has no runtime existence: types are erased, so a JavaScript caller, an `any`-typed path, or a type assertion walks straight through it; `Object.freeze` is the runtime counterpart if you need one. And it constrains nobody but this function — the caller keeps its `Item[]` reference and can mutate the array while your function is holding it. Practically it also costs interop: you cannot forward the parameter to a helper typed `Item[]` without copying with `[...items]`.

code

typescript · 11 lines
typescript
interface Item { name: string }

function normalize(items: readonly Item[]): Item[] {
  // items.push({ name: 'new' }); // Error: 'push' does not exist on 'readonly Item[]'
  for (const item of items) {
    item.name = item.name.trim(); // allowed — readonly is shallow
  }
  return [...items].reverse();    // copy first, then mutate the copy
}

console.log(normalize([{ name: ' a ' }, { name: 'b' }]));

go deeper

for a junior

Know that readonly on an array parameter stops the function from calling push, sort or splice on it, and that the restriction exists only while the code is being type-checked, not when it runs.

for a middle

Explain the three limits with a concrete line of code each: shallow protection of the slots only, complete erasure at compile time, and no constraint on the caller who still holds the mutable reference.

for a senior

Demonstrate lived experience — the interop copies it forces on existing T[] signatures, why a type assertion to escape it is a defect, and when a defensive copy is the right answer instead of an annotation.

for a principal

Own where the boundary sits: which module surfaces earn the guarantee and the copy cost it imposes on every caller, how the constraint propagates once introduced, and whether runtime enforcement is ever warranted over a compile-time claim.

## The guarantee, stated precisely Annotating a parameter `readonly Item[]` changes exactly one thing: the type the body sees. That type is `ReadonlyArray<Item>`, which lacks the mutating members, so the compiler rejects them: ```typescript function summarize(items: readonly Item[]) { // items.push(x); // error: push does not exist on 'readonly Item[]' // items.sort(); // error: sort does not exist // items[0] = other; // error: index signature is readonly return items.map(i => i.name).join(', '); // reading is untouched } ``` That is a real and useful guarantee. The classic bug it prevents is a helper that calls `sort()` or `reverse()` on a parameter, mutating the caller's array as a side effect nobody documented. With the readonly annotation, the mistake is caught at the point of writing rather than in a bug report about a list that reordered itself. ## Limit 1: it is shallow `readonly Item[]` protects the array's own slots — which object each index holds — and nothing inside those objects: ```typescript function normalize(items: readonly Item[]) { for (const item of items) { item.name = item.name.trim(); // compiles: the elements are mutable } } ``` If the elements must be immutable too, that has to be expressed in the element type — a type whose properties are readonly, or `readonly (readonly number[])[]` for nested arrays. There is no single modifier that makes a type deeply immutable; depth is opt-in at every level, and forgetting that is the most common way a readonly annotation gives false confidence. ## Limit 2: nothing exists at runtime The entire type layer is erased during compilation. The emitted JavaScript for a `readonly Item[]` parameter is identical to that for `Item[]` — no check, no wrapper, no cost. Consequences worth naming in an interview: - A caller written in plain JavaScript, or one that reaches your function through an `any`, is unconstrained. - A type assertion (`items as Item[]`) silences the checker without copying, restoring full mutability with no runtime signal. - Data crossing a boundary — parsed JSON, a message from a worker — arrives with whatever type you claimed for it, not whatever the annotation implies. If you need enforcement rather than a claim, `Object.freeze` is the runtime mechanism; the type annotation and the runtime freeze are independent and you can have either without the other. ## Limit 3: it binds the callee, not the caller The annotation is a promise this function makes about what it will do, not a constraint on the array. The caller usually still holds an `Item[]` pointing at the same object and may mutate it at any time — including while your function is mid-iteration, if you have handed control back through a callback or an `await`. Readonly parameters buy you "I will not corrupt your data", not "this data will not change". ## The cost: interop friction Because assignability runs only from mutable to readonly, a `readonly Item[]` value cannot be passed to anything typed `Item[]`: ```typescript declare function persist(items: Item[]): void; function handle(items: readonly Item[]) { // persist(items); // error persist([...items]); // copy, at the cost of an allocation } ``` The same friction shows up with older library signatures and with in-place methods you actually wanted — sorting a readonly array requires copying first (`[...items].sort()`), unless your `lib` setting includes ES2023, whose `toSorted`, `toReversed` and `with` are declared on readonly arrays as well and return a new array. This is why readonly array parameters tend to be adopted at chosen boundaries — public module APIs, shared domain helpers — rather than sprayed everywhere: each one is a small immutability guarantee bought with a little friction for its callers. ## How to answer the interview version Lead with the precise guarantee, then the three limits in order of how often they burn people: shallow, erased, callee-only. Finish with the interop cost and the boundary-level adoption strategy. That sequence demonstrates you have used the feature in a real codebase rather than reading its documentation, which is the whole point of asking a senior candidate a question whose surface answer is one sentence long.

  • How would you make the elements immutable as well as the array?
    Express it in the element type — give the element's properties readonly modifiers, or nest the modifier for arrays of arrays, as in `readonly (readonly number[])[]`. Immutability is opt-in per level; there is no built-in modifier that applies recursively, and deep helpers built from mapped types are a separate topic with real tradeoffs.
  • If the guarantee is erased, is a readonly parameter worth anything at a module boundary consumed by JavaScript callers?
    Yes, but for a different reason: it documents and enforces the contract on your side, so the function provably does not mutate its input, and the declaration file communicates that to any TypeScript consumer. What it cannot do is stop an untyped caller mutating the array afterwards, so if the invariant must hold at runtime you need a copy or Object.freeze.
  • When would you copy the input instead of annotating it readonly?
    When the function needs to mutate — sorting, deduplicating in place, building a working list — or when it retains the array beyond the call, since the caller can still mutate an array it kept a reference to. A defensive copy costs an allocation but removes the shared-reference hazard entirely; readonly only removes the accidental-mutation-by-me hazard.
  • Does changing a parameter from Item[] to readonly Item[] break existing callers?
    No — mutable arrays are assignable to readonly ones, so every existing call site still compiles. Widening a parameter this way is a safe change. Doing the same to a return type is not: callers who mutated the result now get errors, because they receive a type with the mutating methods removed.

saying these in an interview costs you the question

  • Thinks readonly freezes the array at runtime
  • Expects the annotation to protect element objects
  • Assumes the caller can no longer mutate the array
  • Uses an assertion to pass it to a T[] parameter
  • Believes it adds a runtime check or cost

context