In this article, we’ll look at possible constructs where prohibiting references from being mutated can be beneficial.
Primitives vs Reference 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,
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'
message, we aren’t modifying its memory. We’re merely creating a new string, leaving the original contents of
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 = '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.
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, ;
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
Object.prototype, are also mutable:
const names = ['James', 'Sarah', 'Rob']; names = '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.
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
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.