Right, let’s talk about making things you can’t break. You’ve probably been there: you pass an object to some function, only to find it’s come back… different. Its hair is a mess, it’s missing a few properties, and it’s got some new ones you didn’t ask for. It’s a mutability nightmare.

This is where TypeScript rolls up its sleeves and says, “I got you.” It gives us two primary tools to enforce immutability: the readonly modifier and the Readonly<T> utility type. They’re the bouncers at the club of your data structures, making sure nothing changes once it’s inside.

The readonly Modifier: Your First Line of Defense

The simplest way to signal an intent of immutability is by slapping readonly on a property. It’s like putting a “Do Not Touch” sign on a museum exhibit—it doesn’t change the exhibit itself, but it tells everyone the rules.

interface Config {
  readonly apiUrl: string;
  readonly maxRetries: number;
  apiKey: string; // This one is still mutable
}

const config: Config = {
  apiUrl: 'https://api.example.com',
  maxRetries: 3,
  apiKey: 'secret123'
};

config.apiUrl = 'https://evil.com'; // Error: Cannot assign to 'apiUrl' because it is a read-only property.
config.apiKey = 'newSecret'; // This is fine (if unwise).

Notice what happened there? The readonly modifier is purely a compile-time constraint. TypeScript will throw a red squiggly line at you if you try to reassign apiUrl. But once your code is compiled to JavaScript and running, that object is just a regular JavaScript object. If someone is determined enough to mess with config._proto__, they absolutely can. We’re not creating a perfect fortress; we’re creating a clear contract that decent developers will follow.

Readonly<T>: The Full Immortality Suit

What if you want to make the entire object immutable? Do you have to manually mark every single property as readonly? Please, I have better things to do. Enter Readonly<T>, the utility type that does exactly that.

interface User {
  id: number;
  name: string;
  email: string;
}

const currentUser: Readonly<User> = {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com'
};

currentUser.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property.
currentUser.id = 999; // Also an error.

This is incredibly useful for function parameters. It’s your way of saying, “I will use this object, but I swear on my compiler’s life I will not mutate it.” It makes your functions predictable and safe.

function processUser(user: Readonly<User>) {
  // ... read from user.name, user.email, etc.
  user.name = 'Hacked'; // Nope! Compiler error. Function is safe.
}

The Shallow vs. Deep Immutality Gotcha

Here’s the part where I have to be brutally honest with you. Both readonly and Readonly<T> are shallow. They only work one level deep. If you have nested objects, the inner ones are still fully mutable. This trips everyone up.

interface State {
  readonly user: User;
  readonly connection: { protocol: string; port: number };
}

const myState: State = {
  user: { id: 1, name: 'Alice', email: 'alice@example.com' },
  connection: { protocol: 'https', port: 443 }
};

myState.user = { id: 2, name: 'Bob', email: 'bob@example.com' }; // Error: Cannot assign to 'user'
myState.user.name = 'Carol'; // 😬 This works perfectly fine! No error.
myState.connection.port = 80; // Also works. We have a problem.

See? The readonly on user only prevents you from assigning a whole new User object to the user property. It does nothing to stop you from mutating the properties of that specific object. This is often not what you want.

Best Practices and Workarounds

So how do you achieve deep immutability? You have a few options, each with trade-offs.

  1. Composition with Readonly: For simple cases, just nest more Readonly types. It’s verbose but clear.

    interface State {
      readonly user: Readonly<User>;
      readonly connection: Readonly<{ protocol: string; port: number }>;
    }
    // Now myState.user.name = 'Carol' will also error.
    
  2. Libraries: For serious projects, use a library like Immutable.js or immer. These provide truly immutable data structures but come with a new API to learn.

  3. const Assertions: For object literals, you can use as const to make every property readonly and narrow the types to their literal values. It’s fantastic for configuration objects.

    const appConfig = {
      apiUrl: 'https://api.example.com',
      retries: 3,
      endpoints: ['users', 'posts']
    } as const;
    
    appConfig.retries = 5; // Error!
    appConfig.endpoints.push('comments'); // Error! The array is now readonly too.
    

The key takeaway? Use readonly and Readonly<T> aggressively to document intent and prevent accidental mutations. But always be aware of its shallow nature. It’s a brilliant, powerful tool—just one that doesn’t magically solve every immutability problem by itself. You still have to think about the shape of your data.