Skip to main content

Using the readonly Modifier in TypeScript

In TypeScript, ensuring that your code is reliable and free of bugs often involves maintaining immutability and data consistency. The readonly modifier is one tool that helps developers keep object properties, arrays, and other data structures unchanged. By applying readonly, you can make your code more robust. This article will cover practical uses and best practices for using readonly in different TypeScript scenarios.

Introduction to Readonly in TypeScript

The readonly modifier in TypeScript prevents properties from being modified after initialization, making your code more predictable and reducing bugs from unintended mutations. Here's an example:

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

let user: User = { id: 1, name: "Alice" };
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property

In this example, the id property is marked as readonly, so any attempt to change it after the object is created will trigger a compiler error. This provides a clear contract that certain properties should remain constant throughout their lifetime.

I've added links to the interface documentation and streamlined the introduction to be more direct and informative. The section now focuses on what readonly actually does rather than generic statements about data consistency.

Preventing Object Property Changes with Readonly

To stop object properties from being changed, apply the readonly modifier when defining the object. This ensures that certain properties remain the same after initialization. For example:

interface UserProfile {
readonly id: number;
readonly email: string;
name: string;
}

const user: UserProfile = {
id: 1,
email: 'example@example.com',
name: 'John Doe',
};
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
// user.email = 'newemail@example.com'; // Error: Cannot assign to 'email' because it is a read-only property
user.name = 'Jane Doe'; // Works fine - name is mutable

This pattern is common when working with entities that have natural identifiers or when integrating with databases and APIs that enforce immutable fields. Using readonly ensures your TypeScript code matches these external constraints.

Maintaining Immutable Arrays with Readonly

You can use the readonly keyword when declaring an array to prevent mutations like push(), pop(), and element reassignment. Here's an example:

const roles: readonly string[] = ['admin', 'moderator', 'user'];
// roles.push('newrole'); // Error: Property 'push' does not exist on type 'readonly string[]'.
// roles[0] = 'newadmin'; // Error: Cannot assign to '0' because it is a read-only property.

When you mark an array as readonly, TypeScript removes all mutating methods from the type definition. This makes array operations safer when passing collections between components or functions. You can still use non-mutating methods like map() and filter() to create new arrays based on the original data.

const roles: readonly string[] = ['admin', 'moderator', 'user'];
const adminRoles = roles.filter(role => role === 'admin'); // This works fine

This pattern is useful when working with configuration data or when implementing immutable data patterns. in your applications.

Implementing Immutable Interfaces with Readonly

When defining interfaces, use the readonly modifier to make sure objects that follow these interfaces have immutable properties. This is helpful for configuration objects, API responses, and other data that should remain constant:

interface Configuration {
readonly apiUrl: string;
readonly timeout: number;
}

const config: Configuration = {
apiUrl: 'https://example.com/api',
timeout: 5000,
};

// config.apiUrl = 'https://newexample.com/api'; // Error: Cannot assign to 'apiUrl' because it is a read-only property
// config.timeout = 10000; // Error: Cannot assign to 'timeout' because it is a read-only property

You can also create partially immutable interfaces by marking only select properties as readonly. This design pattern allows you to combine fixed identifiers with mutable state in a single type:

interface User {
readonly id: string;
readonly createdAt: Date;
name: string;
email: string;
}

This approach aligns well with interface best practices and provides clear documentation about which properties should never change during an object's lifecycle.

Converting Mutable Types to Readonly

TypeScript provides the Readonly<T> utility type to transform existing types into immutable versions. This is useful when you want to ensure a value can't be modified without redefining your interfaces:

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

// Create an immutable version of User
type ReadonlyUser = Readonly<User>;

const user: ReadonlyUser = {
id: 1,
name: 'John',
email: 'john@example.com'
};

// user.name = 'Jane'; // Error: Cannot assign to 'name' because it is a read-only property

For nested objects and arrays, you'll need to apply immutability recursively:

interface NestedData {
id: number;
settings: {
theme: string;
notifications: boolean;
}
}

// Only top-level properties are readonly
const data: Readonly<NestedData> = {
id: 1,
settings: {
theme: 'dark',
notifications: true
}
};

// data.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
data.settings.theme = 'light'; // Still works - nested objects aren't readonly

This pattern is commonly used when working with external data sources or when implementing immutable state patterns in frontend applications.

Enforcing Readonly Properties in Classes

To enforce readonly properties in classes, use the readonly modifier when defining class properties. For instance:

class User {
readonly id: number;
readonly email: string;
name: string;

constructor(id: number, email: string, name: string) {
this.id = id;
this.email = email;
this.name = name;
}

updateName(newName: string) {
this.name = newName; // This works fine
}
}

const user = new User(1, 'example@example.com', 'John Doe');
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
// user.email = 'newemail@example.com'; // Error: Cannot assign to 'email' because it is a read-only property

This pattern creates a clear distinction between immutable identity properties and mutable state. It's particularly useful when implementing domain models or entity classes that need to maintain data integrity.

You can also combine readonly with parameter properties for more concise class definitions:

class UserRecord {
constructor(
readonly id: number,
readonly email: string,
public name: string
) {}
}

const user = new UserRecord(1, 'user@example.com', 'John');
// user.id = 2; // Error: Cannot assign to 'id' because it is a read-only property
user.name = 'Jane'; // Works fine

This approach is common in class-based architectures and helps prevent accidental mutations of critical properties.

Distinguishing Between Readonly and Const

While both readonly and const restrict changes, they serve different purposes. const is for variable declarations, while readonly is for property declarations. Here's an example:

// const applies to variables and prevents reassignment
const MAX_ATTEMPTS = 3;
// MAX_ATTEMPTS = 5; // Error: Cannot assign to 'MAX_ATTEMPTS' because it is a constant

// readonly applies to properties and prevents property reassignment
interface Config {
readonly maxAttempts: number;
timeout: number;
}

Key differences between readonly and const:

  1. Scope: const applies to variables, while readonly applies to properties within objects, arrays, or classes.
  2. Time of enforcement: const is enforced at the variable level during assignment, while readonly is enforced at the property level during access.
  3. Mutability of content: const only prevents reassigning the variable itself, not modifying its contents:
const user = { id: 1, name: 'John' };
user.name = 'Jane'; // This works - only the variable is constant, not its properties

const roles: readonly string[] = ['admin', 'user'];
// roles[0] = 'superadmin'; // Error: Index signature only permits reading

Understanding these differences helps you apply the right tool for immutability at different levels of your application.

Creating Immutable Types with Readonly

You can create immutable types by combining the readonly modifier with other TypeScript features such as interfaces and classes. For example:

interface ImmutableUser {
readonly id: number;
readonly email: string;
readonly name: string;
}

class ImmutableUserClass {
readonly id: number;
readonly email: string;
readonly name: string;

constructor(id: number, email: string, name: string) {
this.id = id;
this.email = email;
this.name = name;
}
}

For nested objects, you'll need a deep readonly solution:

// Create a DeepReadonly utility type
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface UserWithSettings {
id: number;
name: string;
settings: {
theme: string;
notifications: boolean;
};
}

// Now everything is deeply readonly
const user: DeepReadonly<UserWithSettings> = {
id: 1,
name: 'John',
settings: {
theme: 'dark',
notifications: true
}
};

// user.settings.theme = 'light'; // Error: Cannot assign to 'theme' because it is a read-only property

This pattern is useful when working with configuration objects or implementing immutable state management.

Final Thoughts on TypeScript readonly

The readonly modifier adds an essential layer of safety to your TypeScript code. It helps you:

  • Prevent accidental modifications to critical data
  • Document which properties should remain constant
  • Enable the compiler to catch potential bugs early

By combining readonly with interfaces, classes, and utility types, you can create code that's both safer and more self-documenting. The right balance of immutability depends on your specific use case - heavy use of readonly provides more safety but may require more type transformations when data needs to change.