Readonly Arrays and Tuples in TypeScript, Type-Safely
AI generated
type
TypeScript
Readonly Arrays and Tuples: Enforcing Immutability
Catch mutation bugs at compile time instead of in production

Arrays are mutable by default in JavaScript, which leads to subtle bugs in larger codebases whenever a function unintentionally changes an array it was handed. TypeScript's readonly arrays and readonly tuples provide a tool to rule out such mutations already at compile time.

9 min read Type system Immutability

1. The Problem of Silent Mutation

When an array is passed to a function, that function can change it without any warning, for example via push, sort, or splice. The caller often doesn't notice until unexpected values suddenly show up elsewhere in the program, because the original array was changed in the meantime.

Such bugs are especially nasty because they only surface at runtime, often far away from their actual cause. TypeScript can prevent this problem already at compile time, once the intent of immutability is captured in the type system.

The problem gets worse in asynchronous code: when an array is passed to several functions running concurrently, an unnoticed mutation in one of them can trigger race conditions that are hard to reproduce and often go undetected in tests.

2. Basics: readonly T[] and ReadonlyArray

An array type can carry the readonly modifier, either as readonly T[] or, equivalently, as ReadonlyArray<T>. Both notations are functionally identical, readonly T[] is simply the shorter syntax.

Once an array is typed as readonly, the compiler hides every mutating method such as push, pop, splice, or sort. Attempting to access one of these methods results in a compile error, while read-only methods such as map, filter, or slice remain available.

The editor supports this check while you type: once a variable is recognized as a readonly array, autocompletion only lists the methods that are actually available, leaving mutating ones out entirely.


function printAll(items: readonly string[]) {
  console.log(items.join(", "));
  items.push("new"); // error: push does not exist on readonly string[]
}

const list: ReadonlyArray<number> = [1, 2, 3];
const doubled = list.map((n) => n * 2); // allowed, returns a new array

3. Readonly Tuples

Tuples, arrays with a fixed length and a fixed type at every position, can also carry readonly. This matters especially since tuples are often used for coordinates, ranges, or return values with several values, where accidental reordering or a length change would have severe consequences.

Without readonly, TypeScript still allows methods like push on a plain tuple despite its fixed length, which can undermine that fixed structure at runtime. A readonly tuple reliably prevents that.

This matters especially for functions that return several values as a tuple, for instance a result paired with an error object in the style popularized by Go, where readonly additionally ensures the caller doesn't accidentally swap or mutate the order of the returned values.


function distance(a: readonly [number, number], b: readonly [number, number]) {
  return Math.hypot(a[0] - b[0], a[1] - b[1]);
}

const origin: readonly [number, number] = [0, 0];
// origin.push(5); // error: push does not exist on a readonly tuple

4. Combining With as const and satisfies

as const automatically produces a readonly tuple type for array literals, using the narrowest literal type at each position. This combination is especially valuable for constants that should never change and whose exact values should be preserved in the type system.

Combined with satisfies, you can additionally validate that such an immutable array literal matches an expected type, without losing the precision gained from as const.

In many projects this pattern fully replaces the need for an enum representing a fixed list of values, since both immutability and the exact literal types are already covered by combining as const with the union type derived from it.


const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
// type: readonly ["Mon", "Tue", "Wed", "Thu", "Fri"]

type Weekday = (typeof weekdays)[number];
// type: "Mon" | "Tue" | "Wed" | "Thu" | "Fri"

5. readonly as a Contract for Function Parameters

A proven pattern is to declare function parameters as readonly by default whenever the function shouldn't modify the array it received. That makes the function's intent immediately clear to callers and prevents accidental mutation inside the function itself.

Conveniently, a function expecting a readonly array parameter also accepts a perfectly normal, mutable array as its argument, since a mutable array always has every capability of an immutable one. The reverse doesn't hold, though.

In React and similar state management contexts, this pattern is especially valuable, since state values should generally be treated as immutable. readonly parameters enforce that convention already at compile time instead of relying purely on team discipline.


function sum(values: readonly number[]): number {
  return values.reduce((total, v) => total + v, 0);
}

const mutable = [1, 2, 3];
sum(mutable); // allowed, mutable[] is compatible with readonly number[]

6. Shallow Immutability Only

An important edge case: readonly only applies at the top level. A readonly array of mutable objects prevents push or splice on the array itself, but it doesn't stop anyone from changing a property of an object it contains.

For true, deep immutability across several nesting levels, you either need nested readonly modifiers on every object type involved or a recursive DeepReadonly utility type, which many projects define themselves.


interface Point {
  x: number;
  y: number;
}

const points: readonly Point[] = [{ x: 0, y: 0 }];
points[0].x = 5; // allowed, since only the array itself is readonly
// points.push({ x: 1, y: 1 }); // error

7. Comparison to Object.freeze at Runtime

readonly is a purely compile-time feature with no runtime check whatsoever. If a readonly-typed array is bypassed via as any or another type assertion, its contents can still be mutated without any error occurring at runtime.

Object.freeze, on the other hand, provides actual protection at runtime: a mutation attempt fails with an exception in strict mode or is silently ignored in non-strict mode. For maximum safety, both techniques can be combined, readonly for static checking during development, Object.freeze for actual protection at runtime.


const config = Object.freeze(["a", "b", "c"]) as readonly string[];
// compile-time protection via readonly
// runtime protection via Object.freeze

8. Best Practices for Everyday Projects

For public function signatures, especially in libraries and shared utility modules, it's worth declaring array parameters as readonly by default, unless the function actually needs to mutate them. That increases flexibility for callers while documenting intent at the same time.

For exported constants, especially configuration lists or lookup tables, the combination of as const and a union type derived from it is the most robust pattern, since it delivers both immutability and maximum type precision.

9. Common Pitfalls

A common mistake is assuming a readonly array also protects at runtime. Without an additional Object.freeze, the underlying array value can still be changed by any code not checked by the TypeScript compiler, such as external JavaScript libraries or type assertions.

A second pitfall concerns the direction of assignment: a mutable array can be assigned to a readonly variable without any issue, but the reverse, from readonly to mutable, requires an explicit type assertion, since the compiler treats that direction as potentially unsafe.

A third, less obvious issue involves libraries with older type definitions that declare array parameters without readonly, even though they never mutate the array internally. In such cases, passing a readonly array can trigger a compile error despite correct runtime behavior, which can only be worked around with a local type assertion.

Aspect Plain array readonly array Object.freeze array
Mutating methods visible to the compiler Yes No Yes, but ineffective
Runtime protection against mutation No No Yes
Compile-time protection against mutation No Yes No
Compatible as an argument for readonly parameters Yes Yes Yes
Depth of immutability None Top level only Top level only

Mironsoft

TypeScript migration, type safety, and team onboarding

A JavaScript codebase without type safety, but no time for a full migration?

We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.

Migration Roadmap

Plan and execute a gradual JS-to-TS migration without big-bang risk.

Strict Mode Rollout

Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.

Team Onboarding

Bring developers up to speed on TypeScript best practices with workshops and reviews.

10. Summary

Readonly Arrays and Tuples

Protection level

Compile time only

Combines well with

as const, satisfies, Object.freeze

Depth

Shallow by default

Recommended for

Function parameters, constants

11. FAQ: Readonly Arrays and Tuples

1What does the readonly modifier do on an array type?
It hides every mutating method such as push, pop, or splice in the type system, so attempting to use them triggers a compile error already at compile time instead of only in production.
2What is the difference between readonly T[] and ReadonlyArray?
Both are functionally identical, readonly T[] is simply the shorter syntax form of ReadonlyArray, and both produce the same compiler behavior.
3Does a readonly array also provide protection at runtime?
No, readonly is a purely compile-time feature. At runtime, the array's contents can still be changed unless the access is checked by the TypeScript compiler, for instance through an external library.
4How do you create a readonly tuple type?
Either through an explicit declaration such as readonly [number, number], or implicitly by appending as const to an array literal, which also preserves the narrowest literal type at every position.
5Does readonly also apply to nested objects inside an array?
No, readonly only applies at the top level by default. Properties of objects inside a readonly array remain mutable unless an additional readonly modifier is set on the object type itself.
6Can a mutable array be assigned to a readonly variable?
Yes, that works without any issue, since a mutable array has every capability of a readonly array. The reverse direction requires an explicit type assertion, since the compiler treats it as unsafe.
7How does readonly differ from Object.freeze?
readonly operates purely at compile time within the type system, while Object.freeze provides actual protection at runtime. For maximum safety, both techniques can be combined without conflicting.
8Why should function parameters often be declared as readonly?
It documents the intent that the function doesn't modify the array it received, increases flexibility for callers, and prevents accidental mutation inside the function itself.
9How do you achieve true deep immutability?
Either through nested readonly modifiers on every affected object type, or through a self-defined recursive DeepReadonly utility type, combined with Object.freeze for extra runtime protection.
10Which pattern works best for immutable constants?
Combining as const on an array literal with a union type derived from it via typeof and index access delivers both immutability and maximum type precision without any extra code.