{"slug": "typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it", "title": "TypeScript `readonly` Arrays and Tuples: When Immutability Saves You and When It Fights You", "summary": "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.", "body_md": "`readonly`\n\nArrays and Tuples: When Immutability Saves You and When It Fights You\n\nThis article was written with the assistance of AI, under human supervision and review.\n\nMost readonly bugs stem from misunderstanding what the type actually prevents. Developers mark an array `readonly`\n\n, 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.\n\nThe problem isn't that `readonly`\n\nis 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.\n\nThe correct pattern requires understanding three layers: what `readonly`\n\nactually 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.\n\n`readonly`\n\nmodifier prevents reassignment and mutation methods on arrays and tuples, but only at the first level—nested objects remain mutable unless explicitly typed.`ReadonlyArray<T>`\n\nand `readonly T[]`\n\nare functionally identical; prefer the latter for brevity in variable declarations and the former in generic constraints where clarity matters.`readonly`\n\nfrom literals unless you use `as const`\n\nassertions or explicit type annotations, creating silent vulnerabilities.`DeepReadonly`\n\nutility types or `as const`\n\nassertions, both of which impose ergonomic and inference costs that don't always justify the safety gain.`readonly`\n\nstrictness 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>`\n\nand `readonly T[]`\n\n. Both compile to identical runtime code and impose the same compile-time restrictions. The distinction is purely ergonomic.\n\nThe `readonly T[]`\n\nsyntax mirrors the standard array syntax with a modifier prefix. This makes it readable in variable declarations and return types. The `ReadonlyArray<T>`\n\nform 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.\n\n``` js\n// Both prevent mutation methods\nconst numbersA: readonly number[] = [1, 2, 3];\nconst numbersB: ReadonlyArray<number> = [1, 2, 3];\n\n// Both compile-time errors\nnumbersA.push(4);  // Error: Property 'push' does not exist\nnumbersB[0] = 99;  // Error: Index signature only permits reading\n\n// Both allow reading\nconst first = numbersA[0];  // Valid: 1\nconst length = numbersB.length;  // Valid: 3\n```\n\nThe type system strips mutation methods (`push`\n\n, `pop`\n\n, `shift`\n\n, `unshift`\n\n, `splice`\n\n, `sort`\n\n, `reverse`\n\n) 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.\n\nThe failure mode appears when developers assume `readonly`\n\nprevents all changes. It doesn't. It prevents changes *to the array container*, not changes *within* the contained values. This distinction is critical.\n\nThe implication here is that `readonly`\n\nworks best for primitive arrays where individual elements can't be mutated anyway. For object arrays, you need a second layer of protection.\n\nTuples in TypeScript represent fixed-length arrays with specific types at each index. Adding `readonly`\n\nto a tuple locks both the structure and the individual element assignments.\n\n``` js\n// Mutable tuple\ntype Config = [string, number, boolean];\nconst config: Config = [\"production\", 8080, true];\nconfig[0] = \"staging\";  // Valid mutation\nconfig.push(\"extra\");   // Error: Tuple length is 3\n\n// Readonly tuple\ntype ReadonlyConfig = readonly [string, number, boolean];\nconst readonlyConfig: ReadonlyConfig = [\"production\", 8080, true];\nreadonlyConfig[0] = \"staging\";  // Error: Cannot assign to readonly index\nreadonlyConfig.push(\"extra\");   // Error: Property 'push' does not exist\n```\n\nThe 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.\"\n\nThe real-world application appears in React hooks. The `useState`\n\nhook returns a readonly tuple `[state, setState]`\n\nto prevent accidental reassignment of the setter function. Without readonly, developers could inadvertently write `state[1] = someOtherFunction`\n\n, breaking the state update mechanism.\n\n```\n// Practical readonly tuple for coordinate pairs\ntype Coordinate = readonly [x: number, y: number];\n\nfunction calculateDistance(a: Coordinate, b: Coordinate): number {\n  // Cannot accidentally mutate the input coordinates\n  const dx = b[0] - a[0];\n  const dy = b[1] - a[1];\n  return Math.sqrt(dx * dx + dy * dy);\n}\n\nconst origin: Coordinate = [0, 0];\nconst point: Coordinate = [3, 4];\nconst distance = calculateDistance(origin, point);  // 5\n```\n\nThe 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.\n\nThe most expensive readonly misconception is that it provides deep immutability. It doesn't. TypeScript's `readonly`\n\nmodifier is shallow—it prevents mutation of the array container but not the objects inside it.\n\n```\ninterface User {\n  id: string;\n  name: string;\n  permissions: string[];\n}\n\nconst users: readonly User[] = [\n  { id: \"1\", name: \"Alice\", permissions: [\"read\"] },\n  { id: \"2\", name: \"Bob\", permissions: [\"read\", \"write\"] }\n];\n\n// This is blocked (good)\nusers.push({ id: \"3\", name: \"Charlie\", permissions: [] });  // Error\n\n// This is allowed (bad)\nusers[0].name = \"Alicia\";  // Valid mutation\nusers[1].permissions.push(\"admin\");  // Valid mutation\n```\n\nThe readonly modifier only prevents reassignment of array indices and structural operations like `push`\n\n. 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.\n\nThe implication here is that readonly arrays of objects require a second layer of protection. You need to make the object properties themselves readonly.\n\n```\ninterface ReadonlyUser {\n  readonly id: string;\n  readonly name: string;\n  readonly permissions: readonly string[];\n}\n\nconst users: readonly ReadonlyUser[] = [\n  { id: \"1\", name: \"Alice\", permissions: [\"read\"] },\n  { id: \"2\", name: \"Bob\", permissions: [\"read\", \"write\"] }\n];\n\n// Now all mutations are blocked\nusers[0].name = \"Alicia\";  // Error: Cannot assign to readonly property\nusers[1].permissions.push(\"admin\");  // Error: Property 'push' does not exist\n```\n\nThis pattern works but introduces ergonomic cost. Every interface in your type hierarchy needs explicit `readonly`\n\nannotations. For deep object graphs, this becomes verbose quickly.\n\nThe 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.\n\nTypeScript's type widening strips `readonly`\n\nfrom 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.\n\n```\nfunction processItems(items: string[]): void {\n  items.push(\"extra\");  // Mutates the input\n}\n\nconst tags = [\"typescript\", \"readonly\"] as const;\nprocessItems(tags);  // Error: readonly string[] not assignable to string[]\n```\n\nThe type system correctly rejects this because `processItems`\n\nexpects 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.\n\nThe 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.\n\n```\nfunction processItems(items: readonly string[]): void {\n  // Can read but not mutate\n  console.log(items.length);\n  // items.push(\"extra\");  // Error: Property 'push' does not exist\n}\n\nconst tags = [\"typescript\", \"readonly\"] as const;\nprocessItems(tags);  // Valid\nprocessItems([\"javascript\", \"node\"]);  // Also valid\n```\n\nThe failure mode appears in third-party library types. If a library function declares `items: string[]`\n\ninstead of `items: readonly string[]`\n\n, you're forced to either cast away readonly or copy the array. Both options defeat the type safety you wanted.\n\nThe 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.\n\nWhen shallow readonly isn't enough, TypeScript provides two patterns for deep immutability: recursive utility types and `as const`\n\nassertions.\n\nA `DeepReadonly`\n\nutility type applies readonly recursively to every property and nested array in an object graph.\n\n```\ntype DeepReadonly<T> = {\n  readonly [P in keyof T]: T[P] extends (infer U)[]\n    ? readonly DeepReadonly<U>[]\n    : T[P] extends object\n    ? DeepReadonly<T[P]>\n    : T[P];\n};\n\ninterface NestedConfig {\n  database: {\n    host: string;\n    port: number;\n    credentials: {\n      username: string;\n      password: string;\n    };\n  };\n  features: string[];\n}\n\nconst config: DeepReadonly<NestedConfig> = {\n  database: {\n    host: \"localhost\",\n    port: 5432,\n    credentials: {\n      username: \"admin\",\n      password: \"secret\"\n    }\n  },\n  features: [\"auth\", \"logging\"]\n};\n\n// All levels are now readonly\nconfig.database.host = \"remote\";  // Error\nconfig.database.credentials.password = \"new\";  // Error\nconfig.features.push(\"metrics\");  // Error\n```\n\nThis pattern provides true immutability guarantees at the cost of type complexity. The `DeepReadonly`\n\ntype is recursive and can degrade inference performance on large object graphs. Use it for configuration objects and domain models where mutation is never valid.\n\nThe `as const`\n\nassertion provides a simpler alternative for literal values. It infers the narrowest possible type with readonly applied at every level.\n\n``` js\n// Without as const\nconst configA = {\n  timeout: 5000,\n  retries: [1, 2, 5, 10]\n};\n// Type: { timeout: number; retries: number[]; }\n\n// With as const\nconst configB = {\n  timeout: 5000,\n  retries: [1, 2, 5, 10]\n} as const;\n// Type: { readonly timeout: 5000; readonly retries: readonly [1, 2, 5, 10]; }\n```\n\nThe `as const`\n\napproach 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`\n\nif that variable is typed as `number`\n\n.\n\nThe tradeoff is between type narrowing and reusability. Use `as const`\n\nfor configuration constants that truly never change. Use `DeepReadonly`\n\nfor data structures where you need immutability with normal type flexibility.\n\nThe decision to use readonly involves three competing concerns: type safety, developer ergonomics, and runtime performance.\n\nTypeScript'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.\n\nThe 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.\n\nThe practical approach is to apply readonly based on domain risk, not as a blanket policy.\n\n**High-risk domains where readonly pays for itself:**\n\n**Medium-risk domains where shallow readonly is sufficient:**\n\n**Low-risk domains where readonly adds no value:**\n\nThe 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.\n\nWhen 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.\n\nNo—`readonly`\n\nonly prevents mutations at the level where it's applied. For arrays, it blocks structural operations like `push`\n\nand 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.\n\nBoth are functionally identical and compile to the same code. Use `readonly T[]`\n\nfor brevity in variable declarations and return types. Use `ReadonlyArray<T>`\n\nin generic constraints or interface definitions where you want to emphasize that the type itself enforces immutability.\n\nUse `as const`\n\nfor literal values where you want the narrowest possible type with deep immutability. Use explicit `readonly`\n\nannotations when you need immutability with normal type flexibility—for example, when a function parameter should accept any readonly array, not just a specific literal.\n\nNo—`readonly`\n\nis 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.\n\nYou either change the function signature to accept `readonly T[]`\n\n(correct solution) or cast away readonly using `as T[]`\n\n(pragmatic workaround for third-party APIs). Copying the array works but defeats the purpose of the immutability guarantee and adds runtime cost.\n\nTypeScript'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.\n\nThe 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.\n\nApply 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.\n\nThat covers the essential patterns for TypeScript readonly arrays and tuples. Apply these in production and the difference will be immediate.", "url": "https://wpnews.pro/news/typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it", "canonical_source": "https://dev.to/jsmanifest/typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it-fights-you-324c", "published_at": "2026-07-31 14:55:33+00:00", "updated_at": "2026-07-31 15:04:54.628115+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["TypeScript"], "alternates": {"html": "https://wpnews.pro/news/typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it", "markdown": "https://wpnews.pro/news/typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it.md", "text": "https://wpnews.pro/news/typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it.txt", "jsonld": "https://wpnews.pro/news/typescript-readonly-arrays-and-tuples-when-immutability-saves-you-and-when-it.jsonld"}}