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.
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); // 3readonly 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.
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 propertyThe 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.
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
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.
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.
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']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.
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; // Errortype-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
// 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.
// 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 narrowingAspect | 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.
// 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
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 tooreadonly properties can only be assigned in the constructor or at the declaration site.readonly, Readonly<T>, ReadonlyArray<T>, shallow vs deep immutability, and how as const compares. Use these tools together to write safer, more expressive TypeScript.