Given `const point: [number, number] = [1, 2]` in TypeScript, why does `point.push(3)` compile even though the type says the tuple holds exactly two elements, and what annotation prevents it?
answer
- a tuple is an Array underneath
- inherited methods know nothing of length
- the union of element types is what push takes
- accepted unsoundness, not an oversight
- the readonly member set has no mutators
basics
~20 sA tuple is an ordinary array at runtime, so it inherits Array's methods, and push is typed to accept the tuple's element types. The length guarantee covers assignment and indexing, not mutation. Annotating the value as readonly [number, number] removes push and the other mutators.
solid answer
~40 sTuple types are built on `Array`, so a tuple value has every array method, and for `[number, number]` the inherited `push` is typed to accept `number`. Nothing in that signature knows about the tuple's fixed length, so `point.push(3)` type-checks and the array really does grow to three elements at runtime — while `point.length` is still the literal type `2` to the checker. This is a deliberate unsoundness: modelling tuples as arrays keeps them free at runtime, and closing the hole would mean giving tuples their own method set. The fix is `readonly [number, number]`, whose apparent members come from `ReadonlyArray`, so `push`, `pop`, `splice` and the rest simply do not exist and the mutation becomes a compile error. Prefer readonly tuples in public signatures for exactly this reason.
code
typescript · 11 linesconst point: [number, number] = [1, 2];
point.push(3); // compiles: push accepts number
console.log(point.length); // 3 at runtime; typed as the literal 2
const entry: [string, number] = ['a', 1];
entry.push('b'); // accepted: 'b' is in string | number
const safe: readonly [number, number] = [1, 2];
// safe.push(3); // Error: 'push' does not exist on a readonly tuple
// safe[0] = 9; // Error: index signature is readonly
console.log(safe[0] + safe[1]); // reads still workgo deeper
Know that a tuple is an ordinary array underneath, so array methods such as push are available on it, and that readonly in front of a tuple type takes those mutating methods away.
Explain the mechanism: a tuple's apparent members come from Array<T> where T is the union of element types, so push type-checks and the length guarantee holds only for assignment and indexing.
Treat it as known unsoundness and act on it — readonly tuples in signatures, mutable ones only inside the scope that builds them — and be able to say why the language accepted the hole to keep tuples free at runtime.
Decide where immutability is enforced across the codebase: which boundaries take readonly types, whether runtime freezing is warranted anywhere, and how the team reasons about aliasing that no annotation can prevent.
## The symptom ```typescript const point: [number, number] = [1, 2]; point.push(3); // compiles console.log(point.length); // prints 3; the checker still types it as 2 ``` The tuple type promised exactly two elements. The compiler accepted a call that broke that promise, and afterwards the type and the value disagree — the type says `length` is `2` while the array holds three items. Anything the checker derives from the length is now wrong. ## Why the compiler allows it TypeScript models a tuple as an array with extra knowledge about its positions. That means a tuple value has the **apparent members of `Array<T>`**, where `T` is the union of the element types. For `[number, number]` the inherited method is effectively `push(...items: number[]): number`. That signature says nothing about length — it cannot, because it is the same declaration every array shares. The fixed length is enforced where the compiler models the shape directly: assigning a differently sized literal fails, an out-of-range literal index fails, destructuring is position-typed. It is not enforced through the mutating methods, which route around the shape entirely. For a mixed tuple this also loosens what the mutators accept, because `T` is the union of all positions: ```typescript const entry: [string, number] = ['a', 1]; entry.push('b'); // accepted — 'b' is in string | number entry.push(2); // also accepted ``` So neither the count nor the ordering survives a `push`. ## Is this a bug? It is a known, accepted piece of **unsoundness**, in the same family as bivariant method parameters and unchecked index access. The design constraint behind it is that TypeScript's types are erased: a tuple must be a plain array at runtime, with no wrapper and no cost. Giving tuples their own mutation-aware method declarations would mean either a separate runtime representation or a much larger set of built-in declarations whose length arithmetic the checker would have to track through every call. The trade the language made is that the mutators stay array-shaped, and you opt into safety with `readonly`. ## The fix: readonly tuples ```typescript const safe: readonly [number, number] = [1, 2]; // safe.push(3); // Error: Property 'push' does not exist on // type 'readonly [number, number]' const sum = safe[0] + safe[1]; // reads are unaffected ``` A `readonly` tuple's apparent members come from `ReadonlyArray`, which declares only the non-mutating methods — `map`, `filter`, `slice`, `concat`, `indexOf` and friends. `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse` and `fill` are absent, so every route that could change the length or reorder the positions is closed. Element assignment is blocked too: `safe[0] = 9` is an error because the indexed positions are readonly. Assignability runs one way: a mutable tuple is assignable to the matching readonly tuple, but not the reverse. That is what makes `readonly` cheap to adopt at a boundary — a parameter typed `readonly [number, number]` still accepts every existing mutable tuple caller, while promising inside the function body that nothing will be mutated. ## Applying it in practice The rule that pays for itself: **use readonly tuples in signatures**, both for parameters you do not mutate and for values you return and expect callers to treat as fixed. ```typescript function distance(a: readonly [number, number], b: readonly [number, number]): number { return Math.hypot(a[0] - b[0], a[1] - b[1]); } ``` That signature documents the contract and enforces it, and because mutable tuples flow in freely it costs callers nothing. Within a function body where you build a tuple and hand it straight out, the mutable form is fine. ## What readonly does not give you It is still compile-time only. `readonly` emits no `Object.freeze`, so a value that reaches your code through `any`, through a type assertion, or from untyped JavaScript can be mutated by whoever holds a mutable-typed reference to the same array. If you need the guarantee at runtime, freeze the array explicitly — `Object.freeze` is a runtime operation and the type layer will not do it for you. The same caution applies to the original hole: `readonly` prevents *your* code from calling `push` through that reference. If the same array is also reachable via a mutable-typed alias elsewhere, the length can still change under you. Confining who holds the mutable reference is a design decision, not something an annotation settles.
- Which argument types does `push` accept on a `[string, number]` tuple, and why?Both `string` and `number`. The inherited method comes from `Array<T>` where `T` is the union of the tuple's element types, so `push` is typed to accept `string | number`. That is why a `push` breaks the ordering guarantee as well as the length one — nothing ties an argument to a particular position.
- Does `readonly` on a tuple do anything at runtime?No. It is erased like every other type construct, so no `Object.freeze` is emitted and the underlying array stays mutable. It prevents mutation through that reference at compile time only; if the same array is also reachable through a mutable-typed alias, or through `any`, it can still change. Freeze explicitly when you need a runtime guarantee.
- Is it disruptive to change a parameter from `[number, number]` to `readonly [number, number]`?Usually not. A mutable tuple is assignable to the matching readonly tuple, so existing callers keep compiling; only the function body loses the mutators it should not have been using. The reverse change is what breaks — a readonly tuple is not assignable to a mutable one.
- How does this hole compare with other unsoundness in TypeScript?It belongs to the same deliberate set as bivariant method parameters, unchecked index reads, and `as` assertions: places where the language traded a guarantee for practicality or for zero runtime cost. The senior habit is knowing which guarantees are real — assignment and indexing here — and which need an opt-in like `readonly`.
saying these in an interview costs you the question
- Calls it a compiler bug rather than accepted unsoundness
- Thinks the tuple's length is checked when methods mutate it
- Believes readonly emits Object.freeze at runtime
- Assumes push on a [string, number] tuple only accepts strings
- Says a readonly tuple cannot be passed a mutable tuple