TypeScriptReadonly & ReadonlyArray

Readonly & ReadonlyArray

TypeScript provides two main ways to enforce immutability at the type level: the Readonly<T> utility type and the readonly modifier. Understanding the difference between them — and their limitations — helps you write safer, more predictable code.

The readonly Modifier

The readonly modifier can be applied directly to individual properties in an interface or class. It signals that a property cannot be reassigned after initialization.

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

const origin: Point = { x: 0, y: 0 };
// origin.x = 1; // Error: Cannot assign to 'x' because it is a read-only property

// Works fine on initialization
const point: Point = { x: 3, y: 4 };
console.log(point.x); // 3
Note
The readonly modifier only prevents reassignment — it does not prevent mutation of nested objects.
Readonly<T> Utility Type

Readonly<T> is a mapped type that applies the readonly modifier to every property of type T automatically. It is equivalent to writing readonly on every field by hand.

TS
interface Config {
  host: string;
  port: number;
  debug: boolean;
}

// Manually readonly version
interface ReadonlyConfig {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

// Using the utility type — identical result
type FrozenConfig = Readonly<Config>;

const config: FrozenConfig = { host: 'localhost', port: 3000, debug: false };
// config.port = 8080; // Error: Cannot assign to 'port' because it is a read-only property

The built-in definition of Readonly<T> in TypeScript's lib is:

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

It maps over every key P in T and adds the readonly modifier.

Readonly<T> vs readonly Modifier

Feature

readonly modifier

Readonly<T>

Scope

Single property

All properties of T

Usage

Inline in type/interface

Wraps an existing type

Flexibility

Can mix readonly and mutable

All-or-nothing

Best for

Fine-grained control

Locking down an entire type

ReadonlyArray<T> and readonly T[]

For arrays, TypeScript offers ReadonlyArray<T> and the shorthand readonly T[]. Both produce the same type — an array whose elements cannot be replaced and whose mutating methods are removed from the type.

TS
const nums: ReadonlyArray<number> = [1, 2, 3];
const strs: readonly string[] = ['a', 'b', 'c'];

// Reading is fine
console.log(nums[0]); // 1
console.log(strs.length); // 3

// Mutations are blocked at compile time
// nums.push(4);      // Error: Property 'push' does not exist on type 'ReadonlyArray<number>'
// nums[0] = 99;      // Error: Index signature in type 'readonly number[]' only permits reading
// strs.splice(0, 1); // Error: Property 'splice' does not exist on type 'readonly string[]'

// Non-mutating methods still work
const doubled = nums.map(n => n * 2); // number[]
const first = strs.find(s => s === 'a'); // string | undefined
Tip
Prefer readonly T[] over ReadonlyArray<T> — it is shorter and reads more naturally alongside other array syntax.
Readonly Arrays vs Mutable Arrays

A mutable array is assignable to a readonly array, but not the other way around. This allows functions that promise not to mutate their input to accept both mutable and readonly arrays.

TS
function sumAll(nums: readonly number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

const mutableList: number[] = [10, 20, 30];
const frozenList: readonly number[] = [1, 2, 3];

sumAll(mutableList); // OK — mutable is assignable to readonly
sumAll(frozenList);  // OK

// The reverse does not hold:
function mutate(nums: number[]) {
  nums.push(99);
}

// mutate(frozenList); // Error: Argument of type 'readonly number[]' is not
//                     // assignable to parameter of type 'number[]'
The Shallow Readonly Problem

Both Readonly<T> and the readonly modifier are shallow. They only prevent reassignment of the top-level properties — nested objects and arrays can still be mutated.

TS
interface UserProfile {
  name: string;
  address: {
    city: string;
    zip: string;
  };
  tags: string[];
}

const profile: Readonly<UserProfile> = {
  name: 'Alice',
  address: { city: 'Toronto', zip: 'M5V' },
  tags: ['admin', 'user'],
};

// Top-level reassignment blocked:
// profile.name = 'Bob'; // Error

// But nested mutation is allowed!
profile.address.city = 'Vancouver'; // No error — address object is still mutable
profile.tags.push('superuser');     // No error — tags array is still mutable

console.log(profile.address.city); // 'Vancouver'
console.log(profile.tags);         // ['admin', 'user', 'superuser']
Warning
Do not confuse Readonly<T> with deep immutability. Nested objects require explicit handling.
Deep Readonly Pattern

TypeScript does not ship a built-in deep readonly utility, but you can create one using recursive conditional types.

TS
type DeepReadonly<T> = T extends (infer U)[]
  ? ReadonlyArray<DeepReadonly<U>>
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: string[];
  };
  version: number;
}

const settings: DeepReadonly<Settings> = {
  theme: {
    colors: { primary: '#007bff', secondary: '#6c757d' },
    fonts: ['Inter', 'Roboto'],
  },
  version: 2,
};

// All levels are now locked:
// settings.theme.colors.primary = '#fff';  // Error
// settings.theme.fonts.push('Arial');       // Error
// settings.version = 3;                     // Error
Tip
Libraries like type-fest ship a production-ready ReadonlyDeep type if you prefer not to maintain your own.
Why Shallow Readonly Is Still Useful

Even without deep immutability, Readonly<T> delivers real value in everyday TypeScript code.

  • Function parameters: prevents accidental reassignment of config objects passed in

  • State management: React state objects should not be directly mutated

  • API response caching: marks cached data as not intended for modification

  • Communicates intent: signals to teammates that a value should be treated as immutable

  • Catches bugs early: many accidental mutations happen at the top level

TS
// Marking function config as readonly
interface RequestOptions {
  url: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  headers: Record<string, string>;
  timeout: number;
}

function fetchData(options: Readonly<RequestOptions>): Promise<unknown> {
  // options.url = '/changed'; // Error — prevents accidental mutation
  return fetch(options.url, {
    method: options.method,
    headers: options.headers,
  }).then(res => res.json());
}

// React-style immutable state update
interface AppState {
  count: number;
  user: string | null;
}

function reducer(state: Readonly<AppState>, action: string): AppState {
  // state.count++; // Error — enforces returning a new object
  switch (action) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
}
as const vs Readonly<T>

The as const assertion and Readonly<T> are related but serve different purposes. as const freezes the literal type of a value (including deep nesting), while Readonly<T> only changes assignability at the type level.

TS
// as const — literal type + deep readonly inference
const COLORS = {
  primary: '#007bff',
  secondary: '#6c757d',
  danger: '#dc3545',
} as const;

// Type: { readonly primary: "#007bff"; readonly secondary: "#6c757d"; readonly danger: "#dc3545" }
// COLORS.primary = '#fff'; // Error
// COLORS.primary is narrowed to the literal "#007bff", not just string

// Readonly<T> — structural readonly, types stay wide
interface Colors {
  primary: string;
  secondary: string;
}

const themeColors: Readonly<Colors> = {
  primary: '#007bff',
  secondary: '#6c757d',
};
// themeColors.primary = '#fff'; // Error
// But themeColors.primary is still typed as string (not a literal)

// as const with arrays
const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const;
// Type: readonly ["GET", "POST", "PUT", "DELETE"]
type HttpMethod = typeof METHODS[number]; // "GET" | "POST" | "PUT" | "DELETE"

// Without as const
const METHODS2 = ['GET', 'POST', 'PUT', 'DELETE'];
// Type: string[] — no literal narrowing

Aspect

as const

Readonly<T>

Depth

Deep (recursive)

Shallow (top-level only)

Type narrowing

Narrows to literal types

Keeps original types

Usage

On value expressions

On type annotations

Runtime effect

None

None

Arrays

ReadonlyArray + tuple literal

ReadonlyArray only

Removing readonly with Mutable

Sometimes you need a mutable version of a readonly type — for example, when building up a value before sealing it. You can create a Mutable<T> utility that strips readonly using the -readonly mapped type modifier.

TS
// Remove readonly from all properties using -readonly
type Mutable<T> = {
  -readonly [P in keyof T]: T[P];
};

interface FrozenPoint {
  readonly x: number;
  readonly y: number;
}

type MutablePoint = Mutable<FrozenPoint>;
// { x: number; y: number } — no readonly

const p: MutablePoint = { x: 0, y: 0 };
p.x = 5; // OK — readonly removed
p.y = 10; // OK

// Common pattern: accumulate, then freeze
function buildConfig(): Readonly<FrozenPoint> {
  const draft: MutablePoint = { x: 0, y: 0 };
  draft.x = 3;
  draft.y = 4;
  return draft; // widened to Readonly on return
}
Readonly in Classes

TS
class Circle {
  readonly radius: number;
  readonly PI = 3.14159;

  constructor(radius: number) {
    this.radius = radius; // OK — assignment in constructor is allowed
  }

  area(): number {
    return this.PI * this.radius ** 2;
  }

  // grow() {
  //   this.radius *= 1.1; // Error: Cannot assign to 'radius' because it is a read-only property
  // }
}

const c = new Circle(5);
console.log(c.area()); // 78.53975
// c.radius = 10;      // Error outside class too
Note
In classes, readonly properties can only be assigned in the constructor or at the declaration site.
Success
You now understand the difference between readonly, Readonly<T>, ReadonlyArray<T>, shallow vs deep immutability, and how as const compares. Use these tools together to write safer, more expressive TypeScript.