A brief introduction to Data Immutability in TypeScript

In this lesson, we are going to learn a few techniques to make values immutable, both at compile-time and at runtime.

A brief introduction to Data Immutability in TypeScript

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 any type assertion because TypeScript won’t let you access or assign a property that doesn’t exist on the object ross object which contains only the firstName and lastName properties.

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.freeze or Object.seal methods 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 firstName property in the compiled JavaScript code. The readonly keyword 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, private or protected access modifiers with readonly keyword provided that access modifier keyword should come before the readonly keyword. You can also use parameter property declaration using just readonly keyword 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 ReadonlyArray global 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 putting readonly keyword before a tuple type such as readonly [number, string].

#typescript #data-immutability