TypeScript readonly Arrays and Tuples: When Immutability Saves You and When It Fights You
Most readonly bugs stem from shallow enforcement and type widening. Learn when TypeScript's readonly protects your arrays and tuples—and when it silently fails.
Most readonly bugs stem from misunderstanding what the type actually prevents. Developers mark an array readonly, ship it to production, and discover that nested objects still mutate freely—or that function parameters reject their readonly values entirely. The failure mode here is subtle but expensive: you get type safety where you don't need it and vulnerability where you do.
The problem isn't that readonly is broken. The problem is that teams treat it as a deep immutability guarantee when TypeScript enforces it only at the surface. This creates a false sense of security that explodes during code review or, worse, at runtime.
flowchart LR
A("Developer marks array readonly") --> B("Nested objects mutate silently")
B --> C("Production bug surfaces")
style C stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct pattern requires understanding three layers: what readonly actually protects, where type widening sabotages you, and when deep immutability patterns justify their cost. Apply these correctly and your type system catches mutation bugs before deployment. Skip them and you're debugging object reference issues in production.
flowchart LR
A("Developer marks array readonly") --> B("Enforce readonly at every layer")
B --> C("Type system blocks mutations")
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- TypeScript's
readonlymodifier prevents reassignment and mutation methods on arrays and tuples, but only at the first level—nested objects remain mutable unless explicitly typed. ReadonlyArray<T>andreadonly T[]are functionally identical; prefer the latter for brevity in variable declarations and the former in generic constraints where clarity matters.- Type widening during function calls strips
readonlyfrom literals unless you useas constassertions or explicit type annotations, creating silent vulnerabilities. - Deep immutability requires recursive
DeepReadonlyutility types oras constassertions, both of which impose ergonomic and inference costs that don't always justify the safety gain. - The tradeoff between
readonlystrictness and developer ergonomics depends on domain risk—financial calculations demand it, UI component props rarely need it.
Understanding readonly Arrays: ReadonlyArray vs readonly T[]
TypeScript provides two syntaxes for readonly arrays: ReadonlyArray<T> and readonly T[]. Both compile to identical runtime code and impose the same compile-time restrictions. The distinction is purely ergonomic.
The readonly T[] syntax mirrors the standard array syntax with a modifier prefix. This makes it readable in variable declarations and return types. The ReadonlyArray<T> form communicates intent more clearly in generic constraints and interface definitions where you want to emphasize that the type itself is readonly, not just a specific instance.
// Both prevent mutation methods
const numbersA: readonly number[] = [1, 2, 3];
const numbersB: ReadonlyArray<number> = [1, 2, 3];
// Both compile-time errors
numbersA.push(4); // Error: Property 'push' does not exist
numbersB[0] = 99; // Error: Index signature only permits reading
// Both allow reading
const first = numbersA[0]; // Valid: 1
const length = numbersB.length; // Valid: 3The type system strips mutation methods (push, pop, shift, unshift, splice, sort, reverse) from the readonly interface. Index access for reading remains available, but index assignment is forbidden. This creates a type-level guarantee that the array structure cannot change.
The failure mode appears when developers assume readonly prevents all changes. It doesn't. It prevents changes to the array container, not changes within the contained values. This distinction is critical.
flowchart TD
A("readonly modifier applied") --> B("Array structure operations blocked")
A --> C("Element read operations allowed")
B --> D("push, pop, splice rejected")
C --> E("Index access, length permitted")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style E stroke:#7c9cf0,fill:#142544,color:#eaf2ff
The implication here is that readonly works best for primitive arrays where individual elements can't be mutated anyway. For object arrays, you need a second layer of protection.
Readonly Tuples: Fixed Structure with Immutability Guarantees
Tuples in TypeScript represent fixed-length arrays with specific types at each index. Adding readonly to a tuple locks both the structure and the individual element assignments.
// Mutable tuple
type Config = [string, number, boolean];
const config: Config = ["production", 8080, true];
config[0] = "staging"; // Valid mutation
config.push("extra"); // Error: Tuple length is 3
// Readonly tuple
type ReadonlyConfig = readonly [string, number, boolean];
const readonlyConfig: ReadonlyConfig = ["production", 8080, true];
readonlyConfig[0] = "staging"; // Error: Cannot assign to readonly index
readonlyConfig.push("extra"); // Error: Property 'push' does not existThe readonly tuple pattern provides stronger guarantees than readonly arrays because the type system enforces both the expected structure and element immutability. This makes readonly tuples ideal for function return values where you want to communicate both "this has exactly three elements" and "you cannot modify them."
The real-world application appears in React hooks. The useState hook returns a readonly tuple [state, setState] to prevent accidental reassignment of the setter function. Without readonly, developers could inadvertently write state[1] = someOtherFunction, breaking the state update mechanism.
// Practical readonly tuple for coordinate pairs
type Coordinate = readonly [x: number, y: number];
function calculateDistance(a: Coordinate, b: Coordinate): number {
// Cannot accidentally mutate the input coordinates
const dx = b[0] - a[0];
const dy = b[1] - a[1];
return Math.sqrt(dx * dx + dy * dy);
}
const origin: Coordinate = [0, 0];
const point: Coordinate = [3, 4];
const distance = calculateDistance(origin, point); // 5The pattern scales to labeled tuples where you want named access without creating a full object interface. This provides tuple structure with object-like documentation at the type level.
The Shallow Trap: Why readonly Doesn't Protect Nested Objects
The most expensive readonly misconception is that it provides deep immutability. It doesn't. TypeScript's readonly modifier is shallow—it prevents mutation of the array container but not the objects inside it.
interface User {
id: string;
name: string;
permissions: string[];
}
const users: readonly User[] = [
{ id: "1", name: "Alice", permissions: ["read"] },
{ id: "2", name: "Bob", permissions: ["read", "write"] }
];
// This is blocked (good)
users.push({ id: "3", name: "Charlie", permissions: [] }); // Error
// This is allowed (bad)
users[0].name = "Alicia"; // Valid mutation
users[1].permissions.push("admin"); // Valid mutationThe readonly modifier only prevents reassignment of array indices and structural operations like push. The objects at each index remain fully mutable. This matters because the type system gives you a false positive—it says "this is readonly" while half the data structure is still vulnerable.
flowchart LR
A("readonly array declared") --> B("Array structure protected")
A --> C("Nested objects mutable")
C --> D("Silent mutation bug")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The implication here is that readonly arrays of objects require a second layer of protection. You need to make the object properties themselves readonly.
interface ReadonlyUser {
readonly id: string;
readonly name: string;
readonly permissions: readonly string[];
}
const users: readonly ReadonlyUser[] = [
{ id: "1", name: "Alice", permissions: ["read"] },
{ id: "2", name: "Bob", permissions: ["read", "write"] }
];
// Now all mutations are blocked
users[0].name = "Alicia"; // Error: Cannot assign to readonly property
users[1].permissions.push("admin"); // Error: Property 'push' does not existThis pattern works but introduces ergonomic cost. Every interface in your type hierarchy needs explicit readonly annotations. For deep object graphs, this becomes verbose quickly.
The tradeoff is between safety and maintainability. If the data structure is critical—configuration objects, financial records, audit logs—the verbosity pays for itself. If you're protecting UI component props that change every render, the cost exceeds the benefit.
When readonly Fights You: Type Widening and Function Parameter Hell
TypeScript's type widening strips readonly from literal arrays during function calls unless you explicitly prevent it. This creates a silent mismatch where your readonly array becomes mutable the moment it crosses a function boundary.
function processItems(items: string[]): void {
items.push("extra"); // Mutates the input
}
const tags = ["typescript", "readonly"] as const;
processItems(tags); // Error: readonly string[] not assignable to string[]The type system correctly rejects this because processItems expects a mutable array and you're passing a readonly one. The frustration comes from the opposite direction—when you want to pass a readonly array to a function that shouldn't mutate it but declares a mutable parameter anyway.
flowchart LR
A("readonly array literal") --> B("Function expects mutable")
B --> C("Type error blocks call")
C --> D("Developer removes readonly or casts")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The correct solution is to declare function parameters as readonly when they don't need mutation rights. This communicates intent and accepts both mutable and readonly arrays.
function processItems(items: readonly string[]): void {
// Can read but not mutate
console.log(items.length);
// items.push("extra"); // Error: Property 'push' does not exist
}
const tags = ["typescript", "readonly"] as const;
processItems(tags); // Valid
processItems(["javascript", "node"]); // Also validThe failure mode appears in third-party library types. If a library function declares items: string[] instead of items: readonly string[], you're forced to either cast away readonly or copy the array. Both options defeat the type safety you wanted.
The pragmatic approach is to annotate your own function parameters as readonly and accept that external libraries may require workarounds. The type system can't protect you from someone else's API design.
Deep Immutability Patterns: DeepReadonly and const Assertions
When shallow readonly isn't enough, TypeScript provides two patterns for deep immutability: recursive utility types and as const assertions.
A DeepReadonly utility type applies readonly recursively to every property and nested array in an object graph.
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends (infer U)[]
? readonly DeepReadonly<U>[]
: T[P] extends object
? DeepReadonly<T[P]>
: T[P];
};
interface NestedConfig {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
features: string[];
}
const config: DeepReadonly<NestedConfig> = {
database: {
host: "localhost",
port: 5432,
credentials: {
username: "admin",
password: "secret"
}
},
features: ["auth", "logging"]
};
// All levels are now readonly
config.database.host = "remote"; // Error
config.database.credentials.password = "new"; // Error
config.features.push("metrics"); // ErrorThis pattern provides true immutability guarantees at the cost of type complexity. The DeepReadonly type is recursive and can degrade inference performance on large object graphs. Use it for configuration objects and domain models where mutation is never valid.
The as const assertion provides a simpler alternative for literal values. It infers the narrowest possible type with readonly applied at every level.
// Without as const
const configA = {
timeout: 5000,
retries: [1, 2, 5, 10]
};
// Type: { timeout: number; retries: number[]; }
// With as const
const configB = {
timeout: 5000,
retries: [1, 2, 5, 10]
} as const;
// Type: { readonly timeout: 5000; readonly retries: readonly [1, 2, 5, 10]; }The as const approach locks everything down—both the object properties and the array elements become literal types. This creates the strongest possible immutability guarantee but also the least flexible type. You can't assign a variable to a property that expects the literal 5000 if that variable is typed as number.
The tradeoff is between type narrowing and reusability. Use as const for configuration constants that truly never change. Use DeepReadonly for data structures where you need immutability with normal type flexibility.
Real-World Tradeoffs: Performance, Ergonomics, and When to Skip readonly
The decision to use readonly involves three competing concerns: type safety, developer ergonomics, and runtime performance.
TypeScript's readonly modifier has zero runtime cost. It exists only at compile time and compiles away completely. The performance consideration is type-checking speed, not execution speed. Deep readonly types with complex recursion can slow down the type checker on large codebases, but the impact is usually negligible until you hit hundreds of deeply nested types.
flowchart LR
A("Choose readonly strategy") --> B{Domain risk}
B -->|High| C("Full readonly enforcement")
B -->|Medium| D("Shallow readonly")
B -->|Low| E("Skip readonly")
C --> F("Configuration, financial data")
D --> G("API responses, cache entries")
E --> H("Temporary UI state")
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style E stroke:#7c9cf0,fill:#142544,color:#eaf2ff
The ergonomic cost comes from annotation burden and type compatibility issues. Every function that accepts a readonly parameter needs the annotation. Every interface needs explicit readonly properties. This verbosity compounds in large codebases where readonly spreads virally—once you mark one type readonly, every type that touches it needs the same treatment to avoid type errors.
The practical approach is to apply readonly based on domain risk, not as a blanket policy.
High-risk domains where readonly pays for itself:
- Application configuration objects that should never mutate after initialization
- Financial calculations where mutating intermediate values creates audit failures
- State snapshots for undo/redo systems where preserving history is critical
- API contracts where mutation breaks the protocol
Medium-risk domains where shallow readonly is sufficient:
- API response objects where you read values but don't need deep immutability
- Cache entries where the cache key is readonly but the value can be mutable
- Event logs where the log entry structure is readonly but individual fields can be processed
Low-risk domains where readonly adds no value:
- Temporary UI component state that changes every render
- Builder patterns where mutation is the intended API
- Internal implementation details of a module where mutation is controlled
The failure mode is cargo-culting readonly everywhere because "immutability is good." That's true in principle but counterproductive when the annotation burden exceeds the safety benefit. The goal is to prevent bugs that matter, not to achieve 100% readonly coverage.
When you do use readonly, document why that specific data structure requires immutability. If you can't articulate the mutation risk you're preventing, you probably don't need readonly there.
Frequently Asked Questions
Does readonly prevent all mutations in TypeScript?
No—readonly only prevents mutations at the level where it's applied. For arrays, it blocks structural operations like push and index assignment, but nested objects remain fully mutable unless their properties are also marked readonly. This shallow enforcement is the most common source of readonly bugs.
Should I use ReadonlyArray or readonly T[] syntax?
Both are functionally identical and compile to the same code. Use readonly T[] for brevity in variable declarations and return types. Use ReadonlyArray<T> in generic constraints or interface definitions where you want to emphasize that the type itself enforces immutability.
When should I use as const instead of readonly?
Use as const for literal values where you want the narrowest possible type with deep immutability. Use explicit readonly annotations when you need immutability with normal type flexibility—for example, when a function parameter should accept any readonly array, not just a specific literal.
Does readonly have any runtime performance cost?
No—readonly is a compile-time-only feature that disappears during compilation. The only performance consideration is type-checking speed for complex recursive readonly types, which rarely matters unless you have hundreds of deeply nested structures.
How do I pass a readonly array to a function that expects a mutable array?
You either change the function signature to accept readonly T[] (correct solution) or cast away readonly using as T[] (pragmatic workaround for third-party APIs). Copying the array works but defeats the purpose of the immutability guarantee and adds runtime cost.
Conclusion
TypeScript's readonly modifier prevents array mutations at compile time, but only when developers understand its shallow enforcement and type widening behavior. The pattern works for primitive arrays and tuples without extra effort. For nested objects, it requires recursive readonly types or const assertions, both of which impose ergonomic costs that don't always justify the safety gain.
The tradeoff between readonly strictness and developer productivity depends on domain risk. Financial calculations and configuration objects demand deep immutability. UI component props and temporary state rarely need it. The failure mode is treating readonly as a universal best practice instead of a targeted tool.
Apply readonly where mutation creates real consequences—data corruption, audit failures, protocol violations. Skip it where the annotation burden exceeds the risk. That distinction separates codebases with meaningful type safety from codebases with readonly everywhere and bugs anyway.
That covers the essential patterns for TypeScript readonly arrays and tuples. Apply these in production and the difference will be immediate.