12.2 Readonly<T>: Preventing Mutation
Right, let’s talk about Readonly<T>. It’s the utility type you reach for when you want to tell the TypeScript compiler, “Look, this object is not to be messed with. Make sure of it.” It’s the digital equivalent of putting a museum artifact behind glass. You can look, but you can’t touch. Or more accurately, your code can look, but it can’t reassign properties.
At its heart, Readonly<T> is brilliantly simple. It takes a type T and produces a new type where every property of T is marked as readonly.
interface Config {
host: string;
port: number;
retry: boolean;
}
const mutableConfig: Config = {
host: "example.com",
port: 8080,
retry: true,
};
mutableConfig.port = 3000; // All good, it's mutable.
const immutableConfig: Readonly<Config> = {
host: "example.com",
port: 8080,
retry: true,
};
immutableConfig.port = 3000; // Error: Cannot assign to 'port' because it is a read-only property.
See? The compiler becomes your very diligent, slightly pedantic, security guard. This is your first line of defense against accidental mutations, especially when passing objects around to functions that promise not to change them (but you don’t entirely trust).
It’s a Shallow Guard, Not a Deep Freeze
Here’s the first “gotcha,” and it’s a big one. Readonly<T> is shallow. It only applies readonly to the properties of T itself. If one of those properties is a reference to another object, like an array or another plain object, the contents of that referenced object are still fully mutable.
interface State {
user: {
name: string;
preferences: string[];
};
}
const readOnlyState: Readonly<State> = {
user: {
name: "Alice",
preferences: ["dark-mode", "notifications-on"],
},
};
readOnlyState.user = { name: "Bob", preferences: [] }; // Error: Cannot assign to 'user'
readOnlyState.user.name = "Carol"; // 😬 No error! The 'readonly' doesn't penetrate deeper.
readOnlyState.user.preferences.push("compact-layout"); // Also no error! The array is mutable.
This trips everyone up. The readonly modifier is like a bouncer at the front door of the State object; it checks your ID when you try to swap out the entire user object. But it doesn’t patrol inside the building. The user object and its preferences array have their own rules, which in this case are the default, mutable ones.
Why You’d Use This in the Real World
The primary use case is for function parameters. It’s a fantastic way to enforce a contract: “I will not mutate the object you pass me.” This makes your code more predictable and safer, especially in a collaborative codebase.
function processConfig(config: Readonly<Config>) {
// I can read from config...
console.log(`Connecting to ${config.host}:${config.port}`);
// But if I try to do this, the compiler slaps my wrist:
// config.retry = false; // Error
}
processConfig(mutableConfig); // This is perfectly safe and allowed.
It signals intent both to the compiler and to anyone reading your code. You’re not just accepting any Config; you’re accepting one that you promise to treat as immutable. It’s a much stronger guarantee than just writing a comment that says // I won't change this, pinky promise!.
The Limits of Its Power
Remember, Readonly<T> is a compile-time feature, not a runtime one. It exists only in the TypeScript universe. Once your code is compiled to JavaScript, all those readonly markers vanish. If you send a Readonly<Config> object to a pure JavaScript function, that function can and will mutate it with wild abandon. This type is for ensuring your code is correct, not for creating truly immutable data structures that can survive the harsh realities of the runtime world. For that, you’d need a library like Immutable.js or to use JavaScript’s Object.freeze() at runtime (though that has its own shallow-freeze problems).
Best Practices and Final Thoughts
Use Readonly<T> liberally for function parameters and any value that should not change after its initial creation. It’s a zero-cost abstraction (at runtime) that adds a significant layer of safety. However, always be mindful of its shallow nature. For deep immutability, you’ll need to either craft a deep Readonly type yourself (which is possible but complex) or use a library designed for that purpose.
It’s not a magic wand, but it’s an incredibly useful screwdriver in your TypeScript toolkit. It makes accidental reassignment a compiler error instead of a late-night debugging session, and frankly, that’s a trade I’ll take any day of the week.