Skip to main content

Utility type readonly in TypeScript

Readonly is a TypeScript utility type that makes every property of T non-reassignable at compile time. Reads work freely. Writes become a compile error.

Theory

TL;DR

  • Think of a museum display case: you can see everything inside but can't move any piece
  • Readonly<T> applies shallow immutability - only top-level properties get locked
  • Nested objects stay fully mutable; this is what catches most developers off guard
  • Use it when an object passes between functions and you want compile-time protection against accidental writes
  • Zero runtime cost - TypeScript erases it before the code runs

Quick example

typescript
interface User { id: number; name: string; } const user: Readonly<User> = { id: 1, name: 'Alice' }; console.log(user.name); // 'Alice' - reads work fine user.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property

TypeScript adds readonly to every key of User. The emitted JavaScript is identical to a plain object assignment - no runtime guards, no overhead.

Key difference: shallow, not deep

Readonly<T> locks direct properties of T. If a property holds an object, that inner object stays mutable. In practice, I've seen this bite developers most often when wrapping a Redux slice or a nested config - the write compiles cleanly and the mutation goes unnoticed until something breaks at runtime.

typescript
interface Settings { theme: string; db: { host: string }; } const settings: Readonly<Settings> = { theme: 'dark', db: { host: 'localhost' } }; // settings.theme = 'light'; // Error - top-level is blocked settings.db.host = 'prod'; // Fine - nested object is not protected

When to use

  • Object passed across multiple functions where accidental side effects would be hard to trace
  • Config or settings created at startup that should never change
  • API response data that should stay read-only downstream
  • React component props to make the no-mutation contract explicit

How the compiler handles this

TypeScript expands Readonly<T> as a mapped type: { readonly [K in keyof T]: T[K] }. It walks each key of T and adds the readonly modifier. Nothing changes in the emitted JavaScript. V8 and Node never see it.

Common mistakes

1. Assuming nested objects are also protected

typescript
type Config = Readonly<{ db: { host: string } }>; const cfg: Config = { db: { host: 'localhost' } }; cfg.db.host = 'production'; // Compiles. No error.

Fix: nest Readonly explicitly or write a recursive mapped type:

typescript
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };

2. Arrays inside Readonly still accept push

typescript
type State = Readonly<{ items: number[] }>; const state: State = { items: [1, 2] }; state.items.push(3); // Allowed - the reference is locked, not the array itself

Fix: Readonly<{ readonly items: readonly number[] }>.

3. Index signatures stay open

Readonly<{ [k: string]: number }> still allows obj['newKey'] = 1. The wrapper doesn't seal dynamic key addition. Write the modifier inline instead: { readonly [k: string]: number }.

Real-world usage

  • React - function TodoItem(props: Readonly<TodoProps>) prevents in-component props mutation; common in Next.js 14+ components
  • Redux - type State = Readonly<{ todos: Todo[] }> appears in official Redux Toolkit examples
  • Server config - type ServerConfig = Readonly<{ port: number; dbUrl: string }> ensures setup objects don't drift after startup
  • Zod - z.object({...}).readonly() produces a Readonly output type from a schema automatically

Follow-up questions

Q: What is the difference between the readonly modifier on a property and Readonly<T>?
A: readonly id: number targets one specific property. Readonly<T> applies to every property of T at once. Both produce identical JavaScript output - nothing.

Q: Does Readonly<T> work with classes?
A: Yes, for public instance properties. Private and protected members are not affected by the mapped type. For classes, adding readonly directly in the class body is more precise.

Q: How do you get true deep immutability?
A: Write a recursive mapped type: type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> }. Libraries like ts-toolbelt ship one out of the box. Immer handles deep immutability at the runtime level.

Q: Any runtime cost?
A: None. TypeScript erases all type constructs during compilation. The output is the same with or without Readonly.

Q: Senior question - why does Readonly<{ [K: string]: T }> not prevent writing new keys?
A: Readonly locks the values at existing keys, not the act of adding new ones. An index signature describes a pattern for dynamic keys, and the mapped type wrapper doesn't change that behavior. Write { readonly [K: string]: T } inline to block both reassignment of existing values and addition of new keys.

Examples

Basic: config object at startup

typescript
interface AppConfig { apiUrl: string; timeout: number; } const config: Readonly<AppConfig> = { apiUrl: 'https://api.example.com', timeout: 5000, }; // config.apiUrl = '/v2'; // Error: Cannot assign to 'apiUrl' console.log(config.apiUrl); // reads always work

Config is set once at startup. Any later write is a compile error, not a silent runtime bug discovered in production.

Intermediate: React component props

typescript
interface TodoProps { id: number; text: string; done: boolean; } function TodoItem({ id, text, done }: Readonly<TodoProps>) { // id = 42; // Error - the destructured binding is read-only return <li>{text} {done ? '(done)' : ''}</li>; }

React's own typings already wrap props in Readonly internally. Doing it explicitly here signals intent to anyone reading the code and catches mutations during development, before anything reaches the browser.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?