TypeScript’s readonly keyword feels like a simple "don’t change me" flag, but its real power lies in how it forces you to change how you think about data flow and function signatures.

Let’s see readonly in action. Imagine you have a configuration object that absolutely should not be modified after initialization.

interface AppConfig {
  readonly apiKey: string;
  readonly apiEndpoint: string;
  readonly featureFlags: ReadonlyArray<string>;
}

const defaultConfig: AppConfig = {
  apiKey: "abc123xyz",
  apiEndpoint: "https://api.example.com",
  featureFlags: ["newDashboard", "betaFeatures"],
};

function initializeApp(config: AppConfig) {
  // This is fine, we're just reading
  console.log(`Initializing with API Key: ${config.apiKey}`);

  // This would be a compile-time error:
  // config.apiKey = "newKey";

  // This would also be a compile-time error:
  // config.featureFlags.push("experimentalFeature");
}

initializeApp(defaultConfig);

Here, readonly on apiKey and apiEndpoint prevents direct assignment. Crucially, ReadonlyArray<string> on featureFlags makes the array itself immutable. You can’t push, pop, splice, or reassign elements. This is key: readonly doesn’t just protect the values in the object, but the structure and methods that could modify that structure.

The core problem readonly solves is the accidental mutation of shared state, especially in larger applications or when passing data between modules. Without it, a function might modify an object that another part of the application is still relying on, leading to unpredictable bugs that are notoriously hard to track down. readonly makes your intent explicit: "this data is meant to be read-only."

Internally, TypeScript uses type information to enforce readonly. When you declare a property or an array element as readonly, TypeScript adds a constraint to the type checker. Any attempt to write to a readonly property or call a mutating method on a ReadonlyArray (or ReadonlyMap, ReadonlySet) will be flagged as an error before your code even runs. It’s not a runtime check; it’s a static analysis that happens during compilation.

The readonly modifier can be applied to properties of an interface or type alias, elements of an array type (using ReadonlyArray<T> or readonly T[]), or even to function parameters to indicate that the function will not mutate the passed-in argument.

Consider this: if you have a function that accepts a readonly MyObject[] as input, you can pass it an array of MyObject or an array of ReadonlyMyObject. TypeScript’s type system is covariant for read-only positions, meaning a more specific read-only type is compatible with a less specific one. However, if the function mutates the array, it must accept a mutable array type.

What most people miss is how readonly interacts with type assertions and as any. While readonly provides compile-time safety, it’s possible to bypass it by explicitly casting to any and then back to a mutable type. For example:

const immutableData: Readonly<{ value: number }> = { value: 10 };
const mutableData = immutableData as any as { value: number };
mutableData.value = 20; // This compiles but is unsafe!
console.log(immutableData.value); // Output: 20

This bypasses TypeScript’s safety net, demonstrating that readonly is a tool for developer discipline and static analysis, not a runtime security mechanism. It guides you to write safer code by making violations obvious during development.

The next step in mastering immutability with TypeScript often involves exploring libraries like Immer, which allow you to write "mutating" code that is then translated into immutable updates, simplifying complex state management patterns.

Want structured learning?

Take the full Typescript course →