Using the readonly Modifier in TypeScript
You've probably had bugs where something unexpectedly modified an object or array that should have stayed constant. Maybe a configuration object got changed deep in a function call, or an array you were passing around got mutated when you weren't looking. TypeScript's readonly modifier helps prevent these issues by marking properties, arrays, and object structures as immutable. This guide covers how to use readonly effectively and the gotchas you need to watch out for.
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.
One thing to understand upfront: readonly is purely a compile-time feature. TypeScript checks your code for violations during development, but after compilation to JavaScript, all readonly annotations disappear. This means you get type safety with zero performance overhead at runtime.
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
Readonly Tuples
The readonly modifier also works with tuples, which is helpful when you have fixed-length arrays with specific types at each position:
// API response with [status, data, timestamp] structure
const apiResponse: readonly [number, string, Date] = [200, 'Success', new Date()];
// apiResponse[0] = 404; // Error: Cannot assign to '0' because it is a read-only property
// apiResponse.push('extra'); // Error: Property 'push' does not exist on type 'readonly [number, string, Date]'
This pattern is useful when working with configuration data, API responses with fixed structures, or when implementing immutable data patterns in your applications.
ReadonlyArray vs readonly Array Syntax
TypeScript gives you two equivalent ways to declare readonly arrays: ReadonlyArray<T> and readonly T[]. Both do the same thing, but there's a subtle difference you should know about.
// These are equivalent for arrays
const config1: ReadonlyArray<string> = ['production', 'staging'];
const config2: readonly string[] = ['production', 'staging'];
However, when you're declaring properties in interfaces or classes, there's an important distinction:
interface DataStore {
// This prevents reassigning the property AND mutating the array
values: ReadonlyArray<string>;
// This only prevents mutating the array, but allows reassigning the property
readonly items: string[];
}
const store: DataStore = {
values: ['a', 'b'],
items: ['x', 'y']
};
// store.values = ['new']; // Error: Cannot assign to 'values'
// store.values.push('c'); // Error: Property 'push' does not exist
store.items = ['new']; // Error: Cannot assign to 'items' because it is a read-only property
// store.items.push('z'); // This would work! The array itself isn't readonly
Which should you use? If you're declaring a standalone variable or function parameter, use readonly T[] since it's more concise. For interface and class properties where you want to prevent both reassignment and mutation, use ReadonlyArray<T>.
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:
- Scope:
constapplies to variables, whilereadonlyapplies to properties within objects, arrays, or classes. - Time of enforcement:
constis enforced at the variable level during assignment, whilereadonlyis enforced at the property level during access. - Mutability of content:
constonly 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.
Readonly's Biggest Gotcha: Aliasing and Mutation
Here's something that catches many developers off guard: readonly only prevents you from modifying a property. If you pass a readonly value to a function that expects a mutable parameter, TypeScript allows it for type compatibility reasons, and that function can modify your "readonly" data.
function updateRoles(roles: string[]) {
roles.push('guest'); // This mutates the array
}
const userRoles: readonly string[] = ['admin', 'moderator'];
updateRoles(userRoles); // TypeScript allows this!
console.log(userRoles); // ['admin', 'moderator', 'guest'] - it was modified!
This happens because TypeScript considers readonly string[] compatible with string[] for assignment purposes. The readonly guarantee only applies to direct access through the readonly reference, not through aliases.
To protect against this, you need to design your function signatures carefully:
// Better: accept readonly parameter
function getRoleCount(roles: readonly string[]): number {
return roles.length;
}
// If you need to modify, create a copy first
function addRole(roles: readonly string[], newRole: string): string[] {
return [...roles, newRole]; // Returns new array, doesn't mutate
}
const userRoles: readonly string[] = ['admin', 'moderator'];
const updated = addRole(userRoles, 'guest'); // userRoles stays unchanged
This is why readonly arrays are sometimes called "shallow" immutable. The type system prevents direct mutations but can't enforce immutability through all possible code paths. When immutability is critical, consider using readonly throughout your function signatures or use libraries that enforce deep immutability.
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;
}
}
When You Need Deep Readonly
The built-in Readonly<T> utility type only makes top-level properties readonly. Nested objects remain mutable:
interface AppConfig {
database: {
host: string;
port: number;
};
cache: {
ttl: number;
};
}
const config: Readonly<AppConfig> = {
database: { host: 'localhost', port: 5432 },
cache: { ttl: 3600 }
};
// config.database = { host: 'prod', port: 5432 }; // Error: can't reassign top-level
config.database.host = 'prod'; // This works! Nested objects aren't protected
For truly immutable nested structures, you need a recursive DeepReadonly type:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const deepConfig: DeepReadonly<AppConfig> = {
database: { host: 'localhost', port: 5432 },
cache: { ttl: 3600 }
};
// deepConfig.database.host = 'prod'; // Error: Cannot assign to 'host' because it is a read-only property
DeepReadonly recursively applies readonly to every nested property, making the entire structure immutable. This is particularly useful when working with configuration objects, state management, or any data structure where you want to guarantee no part of it can be modified after creation.
Keep in mind that DeepReadonly isn't built into TypeScript (though it's been requested), so you'll need to define it yourself or use a library like ts-essentials that provides it.
Final Thoughts on TypeScript readonly
The readonly modifier gives you compile-time protection against accidental mutations with zero runtime cost. Use it for properties that shouldn't change after initialization, like IDs, timestamps, and configuration values. Remember that it's compile-time only, so it won't prevent mutations in plain JavaScript code or through aliasing.
For arrays, choose between readonly T[] and ReadonlyArray<T> based on whether you're declaring standalone variables (use the first) or interface properties where you want to prevent both reassignment and mutation (use the second). When you have nested objects that all need to be immutable, reach for DeepReadonly instead of the built-in Readonly<T>.
The biggest gotcha is aliasing: passing readonly data to functions with mutable parameters allows those functions to modify your "readonly" values. Design your function signatures to accept readonly parameters when you don't need to mutate, and create copies when you do. This keeps the immutability guarantees intact throughout your codebase.