JavaScript is not so strong when it comes to data immutability. Internally, all primitive data types like string, number, boolean etc. are immutable which means you can’t mutate the value once it is set to a variable. You can only assign a new value to a variable and the old one will be garbage collected by the JavaScript engine when the time comes.
That’s not the same story with objects. If you create a plain object using an object literal, you can override the value of a property or add or remove a property to or from an object whenever you want.
var obj = { a: 1 }; // define an object
obj.b = 2; // add new property
obj.c = "hello"; // add new property
obj.b = 3; // update property value
delete obj.b; // delete property
Objects are by default mutable as shown here and they are passed by reference (but not in a way that operates in other languages). When you assign an object to a variable, the object value doesn’t get copied. JavaScript only assigns a reference of the object to the new variable. The same principle applies when you pass an object as an argument to a function or returns an object from a function. However, there are preventive mechanisms you can apply to make objects immutable.
JavaScript provides the Object.defineProperty function to add a new property on an object with customized property settings. You can also use it to modify settings of an existing property. These settings are called a property descriptor.
💡 You can follow the MDN documentation to know more about the property descriptor or read the first part of my article on JavaScript decorators.
// property-descriptor.ts
let ross = {
firstName: "Ross",
lastName: "Geller",
};
console.log("[init] firstName =>", ross.firstName);
const getDescriptor = (obj: object, key: string) => {
return Object.getOwnPropertyDescriptor(obj, key);
};
const pd = getDescriptor(ross, "firstName");
console.log("pd.writable =>", pd?.writable);
ross.firstName = "Judy";
console.log("[before] firstName =>", ross.firstName);
Object.defineProperty(ross, "firstName", {
writable: false,
});
const pdAfter = getDescriptor(ross, "firstName");
console.log("pd.writable =>", pdAfter?.writable);
ross.firstName = "Jack";
console.log("[after] firstName =>", ross.firstName);
$ ts-node property-descriptor.ts
[init] firstName => Ross
pd.writable => true
[before] firstName => Judy
pd.writable => false
[after] firstName => Judy
In this example, we have created a ross object using object literal expression. Since by default, object properties are writable, we can assign a new value to firstName property and we can see it changing.
The writable option of the property descriptor if set to false makes the property read-only. So even though we have assigned string value Jack to the ross.firstName, the value stays the same. However, in strict mode, it will throw an error since we are trying to modify the value of a read-only property.
If you want to add new properties or modify existing properties with custom property descriptors at once, then you can use Object.defineProperties function. If you want to create a fresh object with custom property descriptors, then you can go for Object.create function.
Whichever the case, handling data immutability is tough in JavaScript. But there are some quick and easy methods that you can implement to make your life less miserable.
JavaScript provides the Object.freeze method to freeze an object. After that, you cannot add or remove properties, override property values, or reconfigure property descriptors.
// object-freeze.ts
let ross = {
firstName: "Ross",
lastName: "Geller",
};
console.log("[init] ross =>", ross);
ross.firstName = "Judy";
console.log("[before] ross =>", ross);
Object.freeze(ross);
ross.firstName = "Jack";
(ross as any).age = 30;
console.log("[after] ross =>", ross);
$ ts-node object-freeze.ts
[init] ross => { firstName: 'Ross', lastName: 'Geller' }
[before] ross => { firstName: 'Judy', lastName: 'Geller' }
[after] ross => { firstName: 'Judy', lastName: 'Geller' }
In this example, we have created a simple ross object. This object is mutable in every which way, so we froze it using Object.freeze method. Now the object is basically dead. As you can see, we tried to override a property value and add a new property, but nothing happened. In strict mode, this operation will throw an error at runtime.
💡 The reason we used
ross as anytype assertion because TypeScript won’t let you access or assign a property that doesn’t exist on the objectrossobject which contains only thefirstNameandlastNameproperties.
JavaScript also provides Object.seal method which is less aggressive compared to freeze method. This method prevents a new property being added to the object or configuring existing properties. It won’t prevent you from assigning new values to properties as long as they are writable.
💡 The most important thing to be careful about
Object.freezeorObject.sealmethods are that these methods only work on the properties of an object. If the value of a property is another object, its properties won’t be affected. So techically, your object is not immutable as one can modify the nested property value.
Immutability using the const keyword
ES6 brought a lot of good features to the language one of which is the let and const keywords. We were used to declaring variables using var but there was no mechanism to create constants in JavaScript. But in modern JavaScript, const declares a constant.
When a variable is declared using const, its value is fixed as in you won’t be able to mutate it, ever. The const keyword, however, is the most misunderstood one. A constant doesn’t make the value immutable, it just prevents the assignment of the new value to the variable name.
// const-keyword.ts
const ross = {
firstName: "Ross",
lastName: "Geller",
};
ross.firstName = "Jack";
console.log(ross.firstName);
ross = null;
$ tsc const-keyword.ts
const-keyword.ts:10:1 - error TS2588: Cannot assign to 'ross' because it is a constant.
Found 1 error.
In this program, we created a constant ross which is a plain JavaScript object. We can still modify the firstName and lastName property values since they are not read-only. However, we won’t be able to change the value of ross constant using a value assignment syntax.
Immutability using the readonly keyword
The const keyword is a JavaScript keyword which means you can make a variable immutable natively. However, TypeScript provides a readonly keyword that can be used as a compile-time check to avoid mutation of object properties, class properties, array, etc.
Read-only Interface fields
TypeScript provides readonly keyword that can be used to annotate a field in an interface as read-only. Any attempt to override the property value marked readonly will result in a compile-time error.
// readonly-interface-field.ts
interface Person {
readonly firstName: string;
lastName: string;
}
let ross: Person = {
firstName: "Ross",
lastName: "Geller",
};
ross.lastName = "Tribbiani";
ross.firstName = "Joey";
$ tsc readonly-interface-field.ts
readonly-interface-field.ts:12:6 - error TS2540: Cannot assign to 'firstName' because it is a read-only property.
Found 1 error.
In this program, the firstName field of the Person interface is read-only. Since ross is a type of Person, TypeScript won’t let us assign a new value to the firstName field.
💡 TypeScript doesn’t modify the property descriptor of the
firstNameproperty in the compiled JavaScript code. Thereadonlykeyword is just a compile-time check but it should suffice the need in most of the cases.
Read-only Class fields
Like an interface, we can mark fields of a class read-only. We need to use the same readonly keyword before the field name declarations.
// readonly-class-field.ts
class Person {
readonly firstName: string;
public lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
}
let ross: Person = new Person("Ross", "Geller");
ross.firstName = "Jack";
$ tsc readonly-class-field.ts
readonly-class-field.ts:13:6 - error TS2540: Cannot assign to 'firstName' because it is a read-only property.
Found 1 error.
In this example, we have marked firstName field of the Person class read-only. We are allowed to set the initial value of the firstName field from within the constructor function. But once the instance object is created, we won’t be able to mutate its value.
💡 You can also use
public,privateorprotectedaccess modifiers withreadonlykeyword provided that access modifier keyword should come before thereadonlykeyword. You can also use parameter property declaration using justreadonlykeyword or alongside an access modifier.
Array Immutability
There is no efficient way to freeze arrays in JavaScript at the moment. So all you can do is hope that someone in your team hasn’t written a code that mutates an array. But TypeScript does have a mechanism to prevent this.
You can use readonly keyword in the type annotation of an array. This instructs TypeScript to forbids any value addition to the array or changing the length of an array. Though this doesn’t prevent assigning a new array value to the array variable, so there is no harm throwing const keyword in there.
// read-only-array.ts
const oddNumbers: readonly number[] = [1, 3, 5, 7, 9];
oddNumbers[0] = 11;
oddNumbers.push(11);
oddNumbers.pop();
oddNumbers.length = 6;
oddNumbers = [1, 2, 3];
let numbers: number[] = oddNumbers;
$ tsc read-only-array.ts
read-only-array.ts:4:1 - error TS2542: Index signature in type 'readonly number[]' only permits reading.
read-only-array.ts:5:12 - error TS2339: Property 'push' does not exist on type 'readonly number[]'.
read-only-array.ts:6:12 - error TS2339: Property 'pop' does not exist on type 'readonly number[]'.
read-only-array.ts:7:12 - error TS2540: Cannot assign to 'length' because it is a read-only property.
read-only-array.ts:8:1 - error TS2588: Cannot assign to 'oddNumbers' because it is a constant.
read-only-array.ts:10:5 - error TS4104: The type 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'.
As you can see from this program, any attempt to modifying the internal structure of an array resulted in failure. Similar to an object, arrays are also passed by reference since an array is basically an object. So, the TypeScript compiler won’t let you assign a read-only array to a mutable array.
💡 You can also use
ReadonlyArrayglobal utility type provided by the TypeScript to create read-only arrays but we will look into this in the next tutorial. Similar to array, you can also create read-only tuple by puttingreadonlykeyword before a tuple type such asreadonly [number, string].