TypeScript Readonly Properties

The readonly modifier lets you mark a property on an interface, type, or class so it can only be assigned once — when the object is created or, for classes, inside the constructor. After that, the compiler blocks any further assignment. It’s TypeScript’s main tool for expressing “this value shouldn’t change after setup” directly in the type system, catching accidental mutation at compile time instead of at runtime.

Overview: How readonly works

readonly is a purely compile-time construct. It exists only in the type checker’s model of your code — when TypeScript compiles to JavaScript, the readonly keyword is erased completely, exactly like every other type annotation. This has an important practical consequence: readonly gives you zero runtime protection. If some untyped JavaScript, a type assertion, or a JSON.parse result touches your object, nothing stops it from being mutated at runtime. The guarantee is a static one: as long as your code goes through the type checker, it can’t compile a direct reassignment of a readonly property outside of its point of initialization.

You can apply readonly in four main places:

  • On a property inside an interface or object type.
  • On a class field, where it can only be assigned in the field initializer or the constructor.
  • On array and tuple types, via readonly T[], ReadonlyArray<T>, or a readonly tuple.
  • Via the built-in Readonly<T> mapped type, which produces a new type with every property of T made readonly.

A critical detail: readonly is shallow. If a readonly property holds an object, the property itself can’t be reassigned to a different object, but the fields inside that object are untouched by the modifier and remain fully mutable. This trips up many developers who assume readonly works recursively, the way immutable data structures do in other languages. TypeScript’s structural type system also means readonly is checked based on shape and modifiers, not on the runtime identity of an object — assigning a mutable object to a readonly-typed variable is allowed (and common), because the check is about what operations the type system permits going forward, not about how the object was originally declared.

Syntax

interface TypeName {
  readonly propertyName: PropertyType;
}

class ClassName {
  readonly fieldName: FieldType = initialValue;

  constructor(value: FieldType) {
    this.fieldName = value; // allowed: still inside the constructor
  }
}
  • readonly — placed before the property name, after any visibility modifier (public/private/protected) on a class field.
  • Interface/type properties — once an object literal is assigned to the type, that property can never be reassigned through a variable of that type.
  • Class fields — can be assigned in the declaration itself or anywhere inside the constructor body, but nowhere else (not in other methods, not from outside the class).
  • Parameter properties — writing readonly directly on a constructor parameter (e.g. constructor(readonly id: string)) both declares the field and marks it read-only in one step.

The Readonly<T> utility type

Instead of marking every property by hand, you can wrap an existing type: Readonly<Book> produces a new type where every property of Book is readonly. This is a mapped type built into the standard library, roughly defined as { readonly [K in keyof T]: T[K] }.

interface Book {
  title: string;
  author: string;
}

const book: Readonly<Book> = { title: "Dune", author: "Frank Herbert" };
console.log(`${book.title} by ${book.author}`);

Output:

Dune by Frank Herbert

Note that Readonly<T> is exactly as shallow as manually-written readonly properties — it only freezes the top-level keys of T, not nested objects.

Readonly index signatures

You can also make an index signature read-only, which is useful for lookup objects you build once and only ever read from afterward: interface Scores { readonly [key: string]: number }. Every access via bracket notation on that type is fine, but any assignment through the index signature is rejected.

Examples

Example 1: readonly on an interface

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

const p: Point = { x: 10, y: 20 };
console.log(`Point is at (${p.x}, ${p.y})`);

Output:

Point is at (10, 20)

Both x and y are set once, in the object literal that satisfies the Point type. From that point on, any attempt to write p.x = 5 would be a compile error, even though nothing about the object at runtime is actually frozen.

Example 2: readonly class fields and parameter properties

class BankAccount {
  readonly accountNumber: string;
  private balance: number;

  constructor(readonly owner: string, accountNumber: string, initialBalance: number) {
    this.accountNumber = accountNumber;
    this.balance = initialBalance;
  }

  deposit(amount: number): void {
    this.balance += amount;
    console.log(`Deposited ${amount}. New balance: ${this.balance}`);
  }

  describe(): string {
    return `Account ${this.accountNumber} owned by ${this.owner}`;
  }
}

const acc = new BankAccount("Alice", "ACC-1001", 500);
console.log(acc.describe());
acc.deposit(150);

Output:

Account ACC-1001 owned by Alice
Deposited 150. New balance: 650

This example mixes two styles: accountNumber is a normal readonly field assigned inside the constructor body, while owner is a parameter property — writing readonly directly on the constructor parameter both declares this.owner and locks it, without a separate field declaration or assignment line. Note that balance is not readonly, since deposit needs to mutate it — mixing mutable and immutable fields on the same class is completely normal.

Example 3: readonly arrays and tuples

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

const scores: readonly number[] = [10, 20, 30];
console.log(`Sum: ${sumAll(scores)}`);

const point: readonly [number, number] = [3, 4];
console.log(`Tuple: (${point[0]}, ${point[1]})`);

Output:

Sum: 60
Tuple: (3, 4)

readonly number[] (equivalent to ReadonlyArray<number>) removes mutating methods like push, pop, and splice from the type entirely, and disallows index assignment such as scores[0] = 99. This is especially useful on function parameters: declaring a parameter as readonly number[] documents — and enforces — that the function will not mutate the array the caller passed in. Tuples support the same modifier, fixing both the length and the read-only status of each position.

Under the hood: what the compiler actually checks

When you write this.balance += amount inside deposit, or p.x = 5 anywhere, the type checker looks up the property’s declaration to see if it carries the readonly modifier. If it does, the checker asks one more question: is this assignment textually inside the constructor of the very class that declares the field (for class fields), or is this the initial object literal that creates the value (for interface/type properties)? If yes, the assignment is allowed; if no, TypeScript reports an error and refuses to emit — well, it still emits by default, but tsc --noEmit or a build with noEmitOnError will stop the build.

Crucially, none of this survives compilation. Run the class above through tsc and look at the output .js: there’s no trace of readonly anywhere. The constructor just does this.accountNumber = accountNumber; like any other assignment. This is why readonly cannot be used to protect data from malicious or careless *JavaScript* callers, from code using // @ts-ignore, or from a type assertion like (acc as any).accountNumber = "HACKED" — none of those go through the type checker’s assignment rule. For genuine runtime immutability, you need Object.freeze(), which throws (in strict mode) or silently no-ops on write attempts, and can be combined with readonly so you get both compile-time and runtime protection.

Common Mistakes

Mistake 1: assigning to a readonly property from outside the constructor

class Config {
  readonly version: string = "1.0.0";
}

const config = new Config();
config.version = "2.0.0";

This fails with TS2540: Cannot assign to ‘version’ because it is a read-only property. The field was already initialized in its declaration, and this assignment happens outside any constructor, so it’s rejected outright.

class Config {
  readonly version: string;

  constructor(version: string) {
    this.version = version;
  }
}

const config = new Config("2.0.0");
console.log(config.version);

If the value genuinely needs to vary per instance, pass it into the constructor instead of hardcoding a default — the assignment there is legal, and the field is still locked for the object’s entire lifetime afterward.

Mistake 2: assuming readonly is deep

interface Settings {
  readonly theme: { color: string };
}

const settings: Settings = { theme: { color: "dark" } };
settings.theme.color = "light";
console.log(settings.theme.color);

Output:

light

This actually compiles without error, which surprises many developers. readonly theme only prevents replacing the whole object (settings.theme = { color: "x" } would fail), but the nested color field has no modifier of its own, so mutating it is perfectly legal. The fix is to mark the nested properties readonly too — either by hand or with a recursive “deep readonly” helper type:

interface Settings {
  readonly theme: { readonly color: string };
}

const settings: Settings = { theme: { color: "dark" } };
console.log(settings.theme.color);

Output:

dark

With the inner color also marked readonly, an attempt to write settings.theme.color = "light" now produces a compile error, closing the gap. For deeply nested structures, many teams write a recursive DeepReadonly<T> mapped type instead of annotating every level manually.

Best Practices

  • Default to readonly on class fields and interface properties that represent identity or configuration set once at creation — IDs, timestamps, injected dependencies — and only drop it when you have a concrete reason to mutate.
  • Use readonly T[] or ReadonlyArray<T> on function parameters to document, and enforce, that the function does not mutate the caller’s array.
  • Remember readonly is shallow — for nested objects that must be fully immutable, mark inner properties readonly explicitly or use a deep-readonly utility type.
  • Pair readonly with Object.freeze() when you need actual runtime immutability (e.g. shared configuration objects, Redux-style state), since readonly alone offers no protection once JavaScript or type assertions are involved.
  • Prefer parameter properties (constructor(readonly id: string)) to cut boilerplate when a constructor argument maps directly to a locked field.
  • Use Readonly<T> at call sites (e.g. a function parameter type) to accept an object without allowing the function body to mutate it, even if the original type is mutable.

Practice Exercises

  • Define an interface Employee with readonly id: number, readonly hireDate: string, and a mutable salary: number. Create an employee object, give them a raise by updating salary, and confirm (by reading the TypeScript error message you’d get) that changing id is rejected.
  • Write a class Matrix that stores its data as a readonly number[][] constructor parameter assigned to a readonly field. Add a method get(row: number, col: number): number that reads a value. Then try writing a method that attempts to mutate the matrix in place and observe why it fails.
  • Take the shallow-readonly Settings example from this lesson and extend it with a third nesting level (e.g. theme.font.size). Manually add readonly at every level so the whole structure is truly immutable, then verify a deeply nested mutation attempt is now a compile error.

Summary

  • readonly marks a property so it can be assigned only once — at declaration/initialization for interfaces and object literals, or inside the constructor for class fields.
  • It is a compile-time-only construct: it is fully erased at runtime and provides no protection against untyped code, type assertions, or any.
  • readonly is shallow — it locks the property slot itself, not the contents of an object stored in it; nested properties need their own readonly modifier.
  • readonly T[], ReadonlyArray<T>, and readonly tuples remove mutating array operations from the type.
  • Readonly<T> is a built-in mapped type that makes every property of T readonly in one step.
  • Combine readonly with Object.freeze() when you need real runtime immutability, not just a compile-time guarantee.