- Primitives vs Reference Types
- Mutable References
- What’s the Issue with Mutable References?
- Pseudo-immutable Objects in JavaScript
- Papering Over the Cracks
- Deeply Freezing Literal Expressions with Const Assertions
- Immutable Function Parameters
- A Real-world Example
- Additional Benefits of Immutability
- Immutability Isn’t a Silver Bullet
- Summary
- Frequently Asked Questions on Compile-Time Immutability in TypeScript
TypeScript allows us to decorate specification-compliant ECMAScript with type information that we can analyze and output as plain JavaScript using a dedicated compiler. In large-scale projects, this sort of static analysis can catch potential bugs ahead of resorting to lengthy debugging sessions, let alone deploying to production. However, reference types in TypeScript are still mutable, which can lead to unintended side effects in our software.
In this article, we’ll look at possible constructs where prohibiting references from being mutated can be beneficial.
Need a refresher on immutability in JavaScript? Read our guide, Immutability in JavaScript.
Primitives vs Reference Types
JavaScript defines two overarching groups of data types:
- Primitives: low-level values that are immutable (e.g. strings, numbers, booleans etc.)
- References: collections of properties, representing identifiable heap memory, that are mutable (e.g. objects, arrays,
Map
etc.)
Say we declare a constant, to which we assign a string:
const message = 'hello';
Given that strings are primitives and are thus immutable, we’re unable to directly modify this value. It can only be used to produce new values:
console.log(message.replace('h', 'sm')); // 'smello'
console.log(message); // 'hello'
Despite invoking replace()
upon message
, we aren’t modifying its memory. We’re merely creating a new string, leaving the original contents of message
intact.
Mutating the indices of message
is a no-op by default, but will throw a TypeError
in strict mode:
'use strict';
const message = 'hello';
message[0] = 'j'; // TypeError: 0 is read-only
Note that if the declaration of message
were to use the let
keyword, we would be able to replace the value to which it resolves:
let message = 'hello';
message = 'goodbye';
It’s important to highlight that this is not mutation. Instead, we’re replacing one immutable value with another.
Mutable References
Let’s contrast the behavior of primitives with references. Let’s declare an object with a couple of properties:
const me = {
name: 'James',
age: 29,
};
Given that JavaScript objects are mutable, we can change its existing properties and add new ones:
me.name = 'Rob';
me.isTall = true;
console.log(me); // Object { name: "Rob", age: 29, isTall: true };
Unlike primitives, objects can be directly mutated without being replaced by a new reference. We can prove this by sharing a single object across two declarations:
const me = {
name: 'James',
age: 29,
};
const rob = me;
rob.name = 'Rob';
console.log(me); // { name: 'Rob', age: 29 }
JavaScript arrays, which inherit from Object.prototype
, are also mutable:
const names = ['James', 'Sarah', 'Rob'];
names[2] = 'Layla';
console.log(names); // Array(3) [ 'James', 'Sarah', 'Layla' ]
What’s the Issue with Mutable References?
Consider we have a mutable array of the first five Fibonacci numbers:
const fibonacci = [1, 2, 3, 5, 8];
log2(fibonacci); // replaces each item, n, with Math.log2(n);
appendFibonacci(fibonacci, 5, 5); // appends the next five Fibonacci numbers to the input array
This code may seem innocuous on the surface, but since log2
mutates the array it receives, our fibonacci
array will no longer exclusively represent Fibonacci numbers as the name would otherwise suggest. Instead, fibonacci
would become [0, 1, 1.584962500721156, 2.321928094887362, 3, 13, 21, 34, 55, 89]
. One could therefore argue that the names of these declarations are semantically inaccurate, making the flow of the program harder to follow.
Pseudo-immutable Objects in JavaScript
Although JavaScript objects are mutable, we can take advantage of particular constructs to deep clone references, namely spread syntax:
const me = {
name: 'James',
age: 29,
address: {
house: '123',
street: 'Fake Street',
town: 'Fakesville',
country: 'United States',
zip: 12345,
},
};
const rob = {
...me,
name: 'Rob',
address: {
...me.address,
house: '125',
},
};
console.log(me.name); // 'James'
console.log(rob.name); // 'Rob'
console.log(me === rob); // false
The spread syntax is also compatible with arrays:
const names = ['James', 'Sarah', 'Rob'];
const newNames = [...names.slice(0, 2), 'Layla'];
console.log(names); // Array(3) [ 'James', 'Sarah', 'Rob' ]
console.log(newNames); // Array(3) [ 'James', 'Sarah', 'Layla' ]
console.log(names === newNames); // false
Thinking immutably when dealing with reference types can make the behavior of our code clearer. Revisiting the prior mutable Fibonacci example, we could avoid such mutation by copying fibonacci
into a new array:
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = [...fibonacci];
log2(log2Fibonacci);
appendFibonacci(fibonacci, 5, 5);
Rather than placing the burden of creating copies on the consumer, it would be preferable for log2
and appendFibonacci
to treat their inputs as read-only, creating new outputs based upon them:
const PHI = 1.618033988749895;
const log2 = (arr: number[]) => arr.map(n => Math.log2(2));
const fib = (n: number) => (PHI ** n - (-PHI) ** -n) / Math.sqrt(5);
const createFibSequence = (start = 0, length = 5) =>
new Array(length).fill(0).map((_, i) => fib(start + i + 2));
const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = log2(fibonacci);
const extendedFibSequence = [...fibonacci, ...createFibSequence(5, 5)];
By writing our functions to return new references in favor of mutating their inputs, the array identified by the fibonacci
declaration remains unchanged, and its name remains a valid source of context. Ultimately, this code is more deterministic.
Papering Over the Cracks
With a bit of discipline, we may be able to act upon references as if they are solely readable, but that they disable mutation from happening elsewhere. What’s to stop us introducing a rogue statement to mutate fibonacci
in a remote part of our application?
fibonacci.push(4);
ECMAScript 5 introduced Object.freeze()
, which provides some defense against mutating objects:
'use strict';
const me = Object.freeze({
name: 'James',
age: 29,
address: {
// props from earlier example
},
});
me.name = 'Rob'; // TypeError: 'name' is read-only
me.isTheBest = true; // TypeError: Object is not extensible
Unfortunately it only shallowly prohibits property mutation, and thus nested objects can still be changed:
// No TypeErrors will be thrown
me.address.house = '666';
me.address.foo = 'bar';
One could call this method on all objects across a particular tree, but this quickly proves to be unwieldy. Perhaps we could instead leverage TypeScript’s features for compile-time immutability.
Deeply Freezing Literal Expressions with Const Assertions
In TypeScript, we can use const assertions, an extension of type assertions, to compute a deep, read-only type from a literal expression:
const sitepoint = {
name: 'SitePoint',
isRegistered: true,
address: {
line1: 'PO Box 1115',
town: 'Collingwood',
region: 'VIC',
postcode: '3066',
country: 'Australia',
},
contentTags: ['JavaScript', 'HTML', 'CSS', 'React'],
} as const;
Annotating this object literal expression with as const
results in TypeScript’s computing the most specific, read-only type it can:
{
readonly name: 'SitePoint';
readonly isRegistered: true;
readonly address: {
readonly line1: 'PO Box 1115';
readonly town: 'Collingwood';
readonly region: 'VIC';
readonly postcode: '3066';
readonly country: 'Australia';
};
readonly contentTags: readonly ['JavaScript', 'HTML', 'CSS', 'React'];
}
In other words:
- Open primitives will be narrowed to exact literal types (e.g.
boolean
=>true
) - Object literals will have their properties modified with
readonly
- Array literals will become
readonly
tuples (e.g.string[]
=>['foo', 'bar', 'baz']
)
Attempting to add or replace any values will result in the TypeScript compiler throwing an error:
sitepoint.isCharity = true; // isCharity does not exist on inferred type
sitepoint.address.country = 'United Kingdom'; // Cannot assign to 'country' because it is a read-only property
Const assertions result in read-only types, which intrinsically disallow the invocation of any instance methods that will mutate an object:
sitepoint.contentTags.push('Pascal'); // Property 'push' does not exist on type 'readonly ["JavaScript", "HTML"...]
Naturally, the only means of using immutable objects to reflect different values is to create new objects from them:
const microsoft = {
...sitepoint,
name: 'Microsoft',
} as const;
Immutable Function Parameters
Because const assertions are merely syntactical sugar for typing a particular declaration as a set of read-only properties with literal values, it’s still possible to mutate references within function bodies:
interface Person {
name: string;
address: {
country: string;
};
}
const me = {
name: 'James',
address: {
country: 'United Kingdom',
},
} as const;
const isJames = (person: Person) => {
person.name = 'Sarah';
return person.name === 'James';
};
console.log(isJames(me)); // false;
console.log(me.name); // 'Sarah';
One could resolve this by annotating the person
parameter with Readonly<Person>
, but this only impacts the root-level properties of an object:
const isJames = (person: Readonly<Person>) => {
person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
person.address.country = 'Australia'; // valid
return person.name === 'James';
};
console.log(isJames(me)); // false
console.log(me.address.country); // 'Australia'
There are no built-in utility types to handle deep immutability, but given that TypeScript 3.7 introduces better support for recursive types by deferring their resolution, we can now express an infinitely recursive type to denote properties as readonly
across the entire depth of an object:
type Immutable<T> = {
readonly [K in keyof T]: Immutable<T[K]>;
};
If we were to describe the person
parameter of isJames()
as Immutable<Person>
, TypeScript would also prohibit us from mutating nested objects:
const isJames = (person: Immutable<Person>) => {
person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
person.address.country = 'Australia'; // Cannot assign to 'country' because it is a read-only property.
return person.name === 'James';
};
This solution will also work for deeply nested arrays:
const hasCell = (cells: Immutable<string[][]>) => {
cells[0][0] = 'no'; // Index signature in type 'readonly string[]' only permits reading.
};
Despite Immutable<T>
being a manually defined type, there are ongoing discussions to introduce DeepReadonly<T> to TypeScript, which has more refined semantics.
A Real-world Example
Redux, the extremely popular state management library, requires state to be treated immutably in order to trivially determine if the store needs to be updated. We might have application state and action interfaces resembling this:
interface Action {
type: string;
name: string;
isComplete: boolean;
}
interface Todo {
name: string;
isComplete: boolean;
}
interface State {
todos: Todo[];
}
Given that our reducer should return an entirely new reference if the state has been updated, we can type the state
argument with Immutable<State>
to prohibit any modifications:
const reducer = (
state: Immutable<State>,
action: Immutable<Action>,
): Immutable<State> => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
name: action.name,
isComplete: false,
},
],
};
default:
return state;
}
};
Additional Benefits of Immutability
Throughout this article, we’ve observed how treating objects immutably results in clearer and more deterministic code. There are nonetheless a couple of additional advantages worth raising.
Detecting Changes with the Strict Comparison Operator
In JavaScript, we can use the strict comparison operator (===
) to determine if two objects share the same reference. Consider our reducer in the previous example:
const reducer = (
state: Immutable<State>,
action: Immutable<TodoAction>,
): Immutable<State> => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
// deeply merge TODOs
};
default:
return state;
}
};
As we only create a new reference if a changed state has been computed, we can deduce that strict referential equality represents an unchanged object:
const action = {
...addTodoAction,
type: 'NOOP',
};
const newState = reducer(state, action);
const hasStateChanged = state !== newState;
Detecting changes by strict reference equality is simpler than deeply comparing two object trees, which typically involves recursion.
Memoizing Computations by Reference
As a corollary to treating references and object expressions as a one-to-one relationship (i.e. a single reference represents a exact set of properties and values), we can memoize potentially expensive computations by reference. If we wanted to add an array containing the first 2000 numbers of the Fibonacci sequence, we could use a higher-order function and a WeakMap
to predictably cache the result of an operation upon a particular reference:
const memoise = <TArg extends object, TResult>(func: Function) => {
const results = new WeakMap<TArg, TResult>();
return (arg: TArg) =>
results.has(arg) ? results.get(arg) : results.set(arg, func(arg)).get(arg);
};
const sum = (numbers: number[]) => numbers.reduce((total, x) => total + x, 0);
const memoisedSum = memoise<number[], number>(sum);
const numbers = createFibSequence(0, 2000);
console.log(memoisedSum(numbers)); // Cache miss
console.log(memoisedSum(numbers)); // Cache hit
Immutability Isn’t a Silver Bullet
Like every programming paradigm, immutability has its downsides:
- Copying deep objects with the spread syntax can be verbose, particularly when one is only changing a single primitive value within a complex tree.
- Creating new references will result in many ephemeral memory allocations, which the garbage collection must consequently dispose;. This can thrash the main thread, although modern garbage collectors such as Orinoco mitigate this with parallelization.
- Using immutable types and const assertions requires discipline and cross-team consensus. Particular linting rules are being discussed as a means of automating such practices, but are very much early-stage proposals.
- Many first- and third-party APIs, such as the DOM and analytics libraries, are modeled on the mutation of objects. While particular abstracts can help, ubiquitous immutability across the Web is impossible.
Summary
Mutation-laden code can have opaque intent and result in our software behaving unexpectedly. Manipulating modern JavaScript syntax can encourage developers to operate upon reference types immutably — creating new objects from existing references in lieu of directly modifying them — and complement them with TypeScript constructs to achieve compile-time immutability. It certainly isn’t a foolproof approach, but with some discipline we can write extremely robust and predictable applications that, in the long run, can only make our jobs easier.
Frequently Asked Questions on Compile-Time Immutability in TypeScript
What is the significance of compile-time immutability in TypeScript?
Compile-time immutability in TypeScript is a crucial concept that helps in maintaining the integrity of data throughout the code. It ensures that once a variable is assigned a value, it cannot be changed. This is particularly useful in large codebases where multiple developers are working simultaneously. It helps in avoiding bugs that can occur due to the inadvertent modification of data. Moreover, it makes the code more predictable and easier to understand, thereby improving the overall code quality.
How can I achieve compile-time immutability in TypeScript?
In TypeScript, you can achieve compile-time immutability by using the readonly
modifier. This modifier can be used with properties in an interface or a class. Once a property is marked as readonly
, it cannot be reassigned after its initial assignment. Here’s an example:interface Point {
readonly x: number;
readonly y: number;
}
In this example, the properties x
and y
are read-only and cannot be modified once they are assigned a value.
Can I make an entire object immutable in TypeScript?
Yes, you can make an entire object immutable in TypeScript by using the Readonly
utility type. This type makes all properties of an object read-only. Here’s an example:const point: Readonly<Point> = { x: 10, y: 20 };
In this example, the point
object is entirely immutable, and none of its properties can be modified.
What is the difference between readonly
and const
in TypeScript?
The readonly
modifier and const
keyword in TypeScript both enforce immutability, but they are used in different contexts. The readonly
modifier is used with properties in an interface or a class, while the const
keyword is used with variables. A const
variable cannot be reassigned after its initial assignment.
Can I make an array immutable in TypeScript?
Yes, you can make an array immutable in TypeScript by using the ReadonlyArray
type. This type makes all elements of an array read-only. Here’s an example:const numbers: ReadonlyArray<number> = [1, 2, 3, 4, 5];
In this example, the numbers
array is immutable, and none of its elements can be modified.
What is the impact of immutability on performance in TypeScript?
Immutability can have both positive and negative impacts on performance in TypeScript. On the positive side, it can make the code more predictable and easier to optimize. On the negative side, it can lead to increased memory usage because every modification of an immutable object results in a new object.
How can I enforce immutability in function parameters in TypeScript?
You can enforce immutability in function parameters in TypeScript by using the readonly
modifier. This ensures that the function cannot modify the arguments passed to it. Here’s an example:function drawPoint(point: Readonly<Point>): void {
// ...
}
In this example, the drawPoint
function cannot modify the point
argument.
Can I make a class immutable in TypeScript?
Yes, you can make a class immutable in TypeScript by using the readonly
modifier with all its properties. This ensures that once an instance of the class is created, none of its properties can be modified.
What is the relationship between immutability and functional programming in TypeScript?
Immutability is a core concept in functional programming, and TypeScript supports it well. In functional programming, data is treated as immutable by default. This makes the code more predictable and easier to reason about. It also enables powerful programming techniques like pure functions and referential transparency.
Can I enforce immutability at runtime in TypeScript?
TypeScript’s immutability features like the readonly
modifier and Readonly
utility type are compile-time checks and do not enforce immutability at runtime. However, you can enforce immutability at runtime by using JavaScript’s Object.freeze
function. This function makes an object and its properties read-only.
James is a full-stack software developer who has a passion for web technologies. He is currently working with a variety of languages, and has engineered solutions for the likes of Sky, Channel 4, Trainline, and NET-A-PORTER.