Utility type readonly in TypeScript
Readonly
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
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 propertyTypeScript 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.
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 protectedWhen 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
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:
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };2. Arrays inside Readonly still accept push
type State = Readonly<{ items: number[] }>;
const state: State = { items: [1, 2] };
state.items.push(3); // Allowed - the reference is locked, not the array itselfFix: 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
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 workConfig is set once at startup. Any later write is a compile error, not a silent runtime bug discovered in production.
Intermediate: React component props
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 readyA concise answer to help you respond confidently on this topic during an interview.