cd /news/developer-tools/typescript-readonly-arrays-and-tuple… · home topics developer-tools article
[ARTICLE · art-82030] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

TypeScript `readonly` Arrays and Tuples: When Immutability Saves You and When It Fights You

TypeScript's readonly modifier for arrays and tuples only prevents mutation at the surface level, leaving nested objects mutable, which can create a false sense of security. Developers must understand the distinction between shallow and deep immutability, and use patterns like as const or DeepReadonly where appropriate. The choice between readonly syntaxes (readonly T[] vs ReadonlyArray<T>) is ergonomic, but both enforce the same compile-time restrictions.

read11 min views1 publishedJul 31, 2026

readonly

Arrays and Tuples: When Immutability Saves You and When It Fights You

This article was written with the assistance of AI, under human supervision and review.

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.

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.

readonly

modifier prevents reassignment and mutation methods on arrays and tuples, but only at the first level—nested objects remain mutable unless explicitly typed.ReadonlyArray<T>

and readonly T[]

are functionally identical; prefer the latter for brevity in variable declarations and the former in generic constraints where clarity matters.readonly

from literals unless you use as const

assertions or explicit type annotations, creating silent vulnerabilities.DeepReadonly

utility types or as const

assertions, both of which impose ergonomic and inference costs that don't always justify the safety gain.readonly

strictness and developer ergonomics depends on domain risk—financial calculations demand it, UI component props rarely need it.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: 3

The 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.

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.

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 exist

The 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);  // 5

The 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 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 mutation

The 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.

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 exist

This 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.

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.

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 valid

The 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.

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");  // Error

This 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.

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.

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:

Medium-risk domains where shallow readonly is sufficient:

Low-risk domains where readonly adds no value:

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.

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.

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.

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.

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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @typescript 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/typescript-readonly-…] indexed:0 read:11min 2026-07-31 ·