skip to content

In TypeScript, a `number[]` can be assigned to a variable of type `readonly number[]`, but not the other way round. Why does assignability only flow in that direction?

level: middleimportance: must knowfreq 58%

answer

  1. fewer members is the safe direction
  2. the readonly type is the supertype
  3. gaining push through an alias
  4. missing methods, not a modifier
  5. copy with spread or slice

basics

~20 s

readonly number[] has the reading methods but not the mutating ones, so every number[] already satisfies it. The reverse assignment would hand push and sort to a value whose owner is relying on it not being mutated, so the compiler rejects it.

solid answer

~50 s

`readonly T[]` — spelled `ReadonlyArray<T>` in generic form — is structurally `Array<T>` minus the mutating members: no `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` or `fill`, and a readonly index signature. A mutable array has everything that type requires, so `number[]` is assignable to `readonly number[]`; you are simply agreeing to use fewer capabilities. The reverse fails because the target demands members the source is not guaranteed to expose in a safe way — accepting it would let a callee call `push` on an array the owner has published as immutable, mutating it through an alias. That direction is the safe one precisely because losing capabilities is always sound while gaining them is not. If you genuinely need a mutable array from a readonly one, copy it: `[...xs]` or `xs.slice()` gives you a fresh `T[]`.

code

typescript · 11 lines
typescript
const mutable: number[] = [1, 2, 3];
const view: readonly number[] = mutable; // narrower to wider: allowed

// const back: number[] = view;          // Error: 'readonly number[]' is not assignable to 'number[]'
const copy: number[] = [...view];        // copying is the correct escape hatch
copy.push(4);                            // safe: the original is untouched

// view.push(4);                         // Error: 'push' does not exist on 'readonly number[]'
const longer = view.concat(4);           // reading methods are all present, and return number[]

console.log(mutable.length, longer.length, copy.length);

go deeper

for a junior

Be able to say which direction works and give the reason in one line: a readonly array offers fewer operations, so a normal array already qualifies, while the reverse would let code mutate something promised as immutable.

for a middle

Explain it structurally — ReadonlyArray declares the array surface minus push, splice, sort and friends, so assignability follows from missing members rather than from a special rule. Name spread or slice as the way to get a mutable copy.

for a senior

Bring the aliasing argument: the refused direction is refused because a second mutable reference defeats the guarantee, and a type assertion recreates that hazard without a copy. Be ready to discuss how the constraint propagates through existing T[] signatures.

for a principal

Own the boundary decision — where in the system readonly array types earn their copy cost, how far the constraint spreads once introduced, and why assertions used to escape it should be treated as review-blocking rather than pragmatic.

## What readonly T[] actually is `readonly T[]` and `ReadonlyArray<T>` are two spellings of one built-in type. It is not a wrapper and not a flag on `Array<T>`; it is a separate interface that declares the non-mutating surface of an array — `length`, indexed access, `concat`, `join`, `slice`, `indexOf`, `lastIndexOf`, `every`, `some`, `forEach`, `map`, `filter`, `reduce`, `reduceRight`, `find`, `includes`, `at` — with a readonly index signature, and without the members that change the array in place. That framing answers the question almost by itself. TypeScript's assignability is structural: `S` is assignable to `T` when `S` provides everything `T` requires. ```typescript const mutable: number[] = [1, 2, 3]; const view: readonly number[] = mutable; // fine: Array<number> has every member ReadonlyArray needs // const back: number[] = view; // error: 'readonly number[]' is not assignable to 'number[]' ``` Going left to right you give up capabilities, which is always safe. Going right to left you would be claiming capabilities the source type never promised. ## Why the unsafe direction is genuinely unsafe The danger is aliasing. Suppose the reverse were allowed: ```typescript function addOne(xs: number[]) { xs.push(1); } function publish(frozen: readonly number[]) { // const escape: number[] = frozen; // if this compiled... // addOne(escape); // ...the "immutable" array is mutated here } ``` The caller of `publish` handed over a value on the understanding that `publish` would not modify it. Permitting the downcast would silently break that contract through a second reference to the same object. Nothing at runtime would stop it either — the readonly-ness is purely a compile-time claim — so the checker is the only line of defence, and it holds the line by refusing the assignment. ## The contrast with readonly properties A detail that separates a solid answer from a memorised one: `readonly` on an *object property* does **not** affect assignability. These two types are mutually assignable: ```typescript interface A { readonly x: number } interface B { x: number } declare let a: A; declare let b: B; a = b; // ok b = a; // also ok — the readonly modifier is not compared ``` That is a deliberate, documented piece of unsoundness in the language. Arrays behave differently not because arrays get a special rule, but because their immutability is expressed by *missing members* rather than by a modifier — and missing members are compared. It is worth being able to state this, because candidates often generalise from arrays to properties and get object assignability wrong. ## Getting a mutable array back Since the assignment is refused, you copy: ```typescript declare const view: readonly number[]; const copy1: number[] = [...view]; const copy2: number[] = view.slice(); const copy3: number[] = Array.from(view); ``` All three produce a fresh mutable array, which is the correct outcome: you may mutate your own copy freely without touching the original. Reaching for a type assertion (`view as number[]`) instead is the classic wrong move — an assertion performs no check and no copy, so it reintroduces exactly the aliasing hazard the rule exists to prevent. ## Where the direction bites in practice The one-way rule spreads through a codebase. If you type a parameter as `readonly T[]`, that function cannot pass its parameter into an older helper typed `T[]` without copying. Conversely a function returning `readonly T[]` forces every caller who wants to sort or reverse to copy first. Neither is wrong, but both are decisions with a blast radius, and interviewers like asking about that spread because it reveals whether you have used the feature or only read about it. A useful mental model: `readonly T[]` is the *wider* type — more values are assignable to it, because it demands less. `T[]` is narrower. Assignment always flows from narrower to wider, which is the same direction as `Dog` to `Animal`. Once you see readonly arrays as the supertype rather than as "a restricted version", the direction stops needing memorisation.

  • Does `readonly` on an object property block assignability the same way?
    No — and that is a deliberate hole in the language. `{ readonly x: number }` and `{ x: number }` are mutually assignable, because the readonly modifier on properties is not compared during assignability checks. Arrays behave differently only because their immutability is expressed by absent methods, which structural comparison does examine.
  • What is wrong with writing `view as number[]` when a function demands a mutable array?
    An assertion performs no check and no copy at runtime — it only silences the checker. You end up with a second, mutable reference to the same array the owner published as immutable, which is exactly the aliasing bug the assignability rule prevents. Copy with `[...view]` instead; it costs an allocation and removes the hazard.
  • If `readonly number[]` is the wider type, why is it not the default for parameters?
    Mostly history and interop. Most existing library signatures take `T[]`, so a readonly parameter cannot be forwarded to them without copying, and the friction propagates outward from wherever you introduce it. Teams that adopt it usually start at public API boundaries, where the immutability guarantee is worth the copies, rather than everywhere at once.

saying these in an interview costs you the question

  • Says readonly arrays are frozen at runtime
  • Uses a type assertion to strip readonly
  • Thinks the rule is a special case rather than structural
  • Claims readonly properties block assignability too
  • Believes the mutable array is copied on assignment

context