Learn

/

Readonly & Immutability

Readonly & Immutability

6 patterns

readonly properties, Readonly<T>, ReadonlyArray, and as const for preventing accidental mutations. You'll hit this when a function modifies an array or object it was only supposed to read.

Avoid
interface Config {
  apiUrl: string;
  timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// Oops, accidentally mutated config
config.timeout = -1;
// No error, invalid state
interface Config {
  apiUrl: string;
  timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// Oops, accidentally mutated config
config.timeout = -1;
// No error, invalid state

Prefer
interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// config.timeout = -1;
// Error: Cannot assign to 'timeout'
// because it is a read-only property
interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

// config.timeout = -1;
// Error: Cannot assign to 'timeout'
// because it is a read-only property
Why avoid

Without readonly, any code with a reference to the config object can change its properties. A single accidental assignment like config.timeout = -1 creates an invalid state that affects the entire application. The bug may surface far from where the mutation happened.

Why prefer

The readonly modifier on interface properties prevents reassignment after initialization. This catches accidental mutations at compile time. It is especially valuable for configuration objects that should be set once and never changed.

TypeScript: Readonly Properties
Avoid
interface User {
  name: string;
  email: string;
  role: "admin" | "user";
}

function displayUser(user: User) {
  // Function accidentally mutates input
  user.role = "admin";
  // Caller's object is now corrupted
  console.log(user.name);
}
interface User {
  name: string;
  email: string;
  role: "admin" | "user";
}

function displayUser(user: User) {
  // Function accidentally mutates input
  user.role = "admin";
  // Caller's object is now corrupted
  console.log(user.name);
}

Prefer
interface User {
  name: string;
  email: string;
  role: "admin" | "user";
}

function displayUser(user: Readonly<User>) {
  // user.role = "admin";
  // Error: Cannot assign to 'role'
  console.log(user.name); // OK to read
}

// Original User type is still mutable
// where mutation is intended
function promoteUser(user: User) {
  user.role = "admin"; // OK
}
interface User {
  name: string;
  email: string;
  role: "admin" | "user";
}

function displayUser(user: Readonly<User>) {
  // user.role = "admin";
  // Error: Cannot assign to 'role'
  console.log(user.name); // OK to read
}

// Original User type is still mutable
// where mutation is intended
function promoteUser(user: User) {
  user.role = "admin"; // OK
}
Why avoid

Passing a mutable type to a function gives it permission to modify the object. Since objects are passed by reference, the caller's data is corrupted. This is a common source of bugs in functions that are supposed to only read their input.

Why prefer

Readonly<T> makes all properties of T readonly without changing the original type. Applying it to function parameters signals that the function only reads the data. Functions that need to mutate can still use the original mutable type.

TypeScript: Readonly<T>
Avoid
function getTopScores(
  scores: number[]
): number[] {
  // Sort mutates the original array!
  scores.sort((a, b) => b - a);
  return scores.slice(0, 3);
}

const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is now [99, 88, 73, 42, 17]
// Original order is destroyed
function getTopScores(
  scores: number[]
): number[] {
  // Sort mutates the original array!
  scores.sort((a, b) => b - a);
  return scores.slice(0, 3);
}

const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is now [99, 88, 73, 42, 17]
// Original order is destroyed

Prefer
function getTopScores(
  scores: readonly number[]
): number[] {
  // scores.sort(...);
  // Error: Property 'sort' does not exist
  // on type 'readonly number[]'

  // Must create a new array first:
  return [...scores]
    .sort((a, b) => b - a)
    .slice(0, 3);
}

const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is still [42, 99, 17, 88, 73]
function getTopScores(
  scores: readonly number[]
): number[] {
  // scores.sort(...);
  // Error: Property 'sort' does not exist
  // on type 'readonly number[]'

  // Must create a new array first:
  return [...scores]
    .sort((a, b) => b - a)
    .slice(0, 3);
}

const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is still [42, 99, 17, 88, 73]
Why avoid

Array.sort() mutates the array in place and returns the same reference. When a function sorts its input array, the caller's original data is permanently reordered. This is one of the most common mutation bugs in JavaScript, and readonly arrays catch it at compile time.

Why prefer

readonly number[] (or ReadonlyArray<number>) removes mutating methods like sort, push, pop, and splice from the type. This forces you to copy the array before sorting, preventing accidental mutation of the caller's data. The spread operator creates a shallow copy that is safe to sort.

TypeScript: Readonly Arrays
Avoid
const COLORS = ["red", "green", "blue"];
// Type: string[]

type Color = typeof COLORS[number];
// Type: string (not useful)

function setColor(color: Color) {
  // Accepts any string, not just
  // "red" | "green" | "blue"
}

setColor("banana"); // No error
const COLORS = ["red", "green", "blue"];
// Type: string[]

type Color = typeof COLORS[number];
// Type: string (not useful)

function setColor(color: Color) {
  // Accepts any string, not just
  // "red" | "green" | "blue"
}

setColor("banana"); // No error

Prefer
const COLORS = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]

type Color = typeof COLORS[number];
// Type: "red" | "green" | "blue"

function setColor(color: Color) {
  // Only accepts the three valid colors
}

setColor("red");    // OK
// setColor("banana"); // Error
const COLORS = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]

type Color = typeof COLORS[number];
// Type: "red" | "green" | "blue"

function setColor(color: Color) {
  // Only accepts the three valid colors
}

setColor("red");    // OK
// setColor("banana"); // Error
Why avoid

Without as const, TypeScript widens the array to string[]. Extracting a type with typeof COLORS[number] gives string, which accepts any string value. The intended constraint of only three valid colors is lost, and typos like 'banana' pass without errors.

Why prefer

The as const assertion tells TypeScript to infer the narrowest possible type: a readonly tuple of literal strings instead of a mutable array of string. Indexing with [number] extracts a union of the literal types. This keeps the runtime array and the type in sync from a single source of truth.

TypeScript: Literal Types
Avoid
interface AppState {
  readonly user: {
    name: string;
    preferences: {
      theme: "light" | "dark";
    };
  };
}

const state: AppState = {
  user: {
    name: "Alice",
    preferences: { theme: "light" },
  },
};

// state.user = ...; // Error (readonly)
state.user.name = "Bob"; // No error!
state.user.preferences.theme = "dark"; // No error!
// readonly is only one level deep
interface AppState {
  readonly user: {
    name: string;
    preferences: {
      theme: "light" | "dark";
    };
  };
}

const state: AppState = {
  user: {
    name: "Alice",
    preferences: { theme: "light" },
  },
};

// state.user = ...; // Error (readonly)
state.user.name = "Bob"; // No error!
state.user.preferences.theme = "dark"; // No error!
// readonly is only one level deep

Prefer
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface AppState {
  user: {
    name: string;
    preferences: {
      theme: "light" | "dark";
    };
  };
}

const state: DeepReadonly<AppState> = {
  user: {
    name: "Alice",
    preferences: { theme: "light" },
  },
};

// state.user.name = "Bob"; // Error
// state.user.preferences.theme = "dark";
// Error: Cannot assign to 'theme'
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

interface AppState {
  user: {
    name: string;
    preferences: {
      theme: "light" | "dark";
    };
  };
}

const state: DeepReadonly<AppState> = {
  user: {
    name: "Alice",
    preferences: { theme: "light" },
  },
};

// state.user.name = "Bob"; // Error
// state.user.preferences.theme = "dark";
// Error: Cannot assign to 'theme'
Why avoid

Marking only the top-level property as readonly gives a false sense of security. The nested properties are still fully mutable, so code can change user.name or preferences.theme without any compiler warning. For state management, shallow readonly is often insufficient.

Why prefer

Readonly<T> and the readonly modifier only apply to the immediate properties. Nested objects remain mutable. A recursive DeepReadonly type applies readonly at every level by checking if the value is an object and recursively wrapping it. This provides true immutability for the entire object tree.

TypeScript: Readonly<T>
Avoid
interface CartItem {
  id: string;
  quantity: number;
  price: number;
}

function calculateTotal(items: CartItem[]) {
  let total = 0;
  // Accidentally mutating while iterating
  for (const item of items) {
    item.price = item.price * 1.1; // Add tax
    total += item.price * item.quantity;
  }
  return total;
  // Caller's items now have inflated prices
  // Calling twice doubles the tax
}
interface CartItem {
  id: string;
  quantity: number;
  price: number;
}

function calculateTotal(items: CartItem[]) {
  let total = 0;
  // Accidentally mutating while iterating
  for (const item of items) {
    item.price = item.price * 1.1; // Add tax
    total += item.price * item.quantity;
  }
  return total;
  // Caller's items now have inflated prices
  // Calling twice doubles the tax
}

Prefer
interface CartItem {
  id: string;
  quantity: number;
  price: number;
}

function calculateTotal(
  items: ReadonlyArray<Readonly<CartItem>>
) {
  let total = 0;
  for (const item of items) {
    // item.price = item.price * 1.1;
    // Error: Cannot assign to 'price'
    const priceWithTax = item.price * 1.1;
    total += priceWithTax * item.quantity;
  }
  return total;
  // Caller's data is untouched
}
interface CartItem {
  id: string;
  quantity: number;
  price: number;
}

function calculateTotal(
  items: ReadonlyArray<Readonly<CartItem>>
) {
  let total = 0;
  for (const item of items) {
    // item.price = item.price * 1.1;
    // Error: Cannot assign to 'price'
    const priceWithTax = item.price * 1.1;
    total += priceWithTax * item.quantity;
  }
  return total;
  // Caller's data is untouched
}
Why avoid

Mutating input data inside a calculation function is a common bug that produces incorrect results when the function is called more than once. The tax gets applied again to already-taxed prices, compounding with each call. Readonly parameters make this impossible at compile time.

Why prefer

Combining ReadonlyArray (prevents push/pop/splice) with Readonly on each element (prevents property assignment) provides full protection. The function is forced to use local variables for computed values instead of mutating the input. This makes the function pure and safe to call repeatedly.

TypeScript: Readonly Properties