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.
Table of Contents
- 1. The Problem of Silent Mutation
- 2. Basics: readonly T[] and ReadonlyArray
- 3. Readonly Tuples
- 4. Combining With as const and satisfies
- 5. readonly as a Contract for Function Parameters
- 6. Shallow Immutability Only
- 7. Comparison to Object.freeze at Runtime
- 8. Best Practices for Everyday Projects
- 9. Common Pitfalls
- 10. Summary
- 11. FAQ
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