Immutability Makes Everything it Touches Simpler

Pipes in a city

It’s hard to write immutable objects.

For example, it’s harder to write this:

class Cache {
  // immmutable
  set(key, val) {
    return new Cache(
      ...this.store,
      [key]: val,
    )
  }
}Code language: JavaScript (javascript)

…than this:

class Cache {
  // mutable
  set(key, val) {
    this.store[key] = val;
  }
}Code language: JavaScript (javascript)

So why bother?

Because immutability makes everything it touches simpler.

Let’s look at an example that shows why.

The Cache.ts file below stores values.

It’s the .cache property in the HttpGateway.ts file.

HttpGateway.ts caches requests to this.cache.

Just skim these, the important lines are highlighted:

Mutable

src/gatewayCache.ts

type Store = { [k: string]: { val: unknown; exp: number } };

export default class Cache {
  store: Store;

  constructor(store: Store = {}) {
    this.store = store;
  }

  get(key: string) {
    if (!this.has(key)) {
      throw new Error(`The key ${key} does not exist`);
    }
    return this.store[key]?.val;
  }

  has(key: string) {
    return !!this.store[key] && !this.expired(this.store, key);
  }

  set(key: string, val: unknown, exp = 86400000, now = new Date().getTime()) {
    this.store[key] = {
      val,
      exp: exp + now,
    };
    this.expire();
  }

  private expire() {
    this.store = this.expireMany(this.store, 20);
  }

  private expired(store: Store, key: string, now = new Date().getTime()) {
    if (!store[key]) {
      throw new Error(`The key ${key} does not exist`);
    }

    return now > store[key].exp;
  }

  private expireMany(store: Store, n: number, expired?: Store): Store {
    if (
      expired &&
      (toArray(expired).length <= 0.25 * n || toArray(store).length === 0)
    ) {
      return store;
    }

    const newExpired = this.expireN(store, n);
    return this.expireMany(dissoc(store, newExpired), n, newExpired);
  }

  private expireN(store: Store, n: number, now = new Date().getTime()) {
    return toObject(
      toArray(store)
        .slice(0, n)
        .filter(([, item]) => now > item.exp)
    ) as Store;
  }
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toObject(arr: Array<any>) {
  return arr.reduce(
    (acc, [k, v]) => ({
      ...acc,
      [k]: v,
    }),
    {}
  );
}

function toArray(obj: Store) {
  return Object.entries(obj);
}

/** Gets a copy of a that has no key from b. */
function dissoc(a: Record<string, unknown>, b: Record<string, unknown>) {
  return Object.entries(a).reduce(
    (acc, [k, v]) =>
      k in b
        ? acc
        : {
            ...acc,
            [k]: v,
          },
    {}
  );
}
Code language: TypeScript (typescript)

src/gateway/HttpGateway.ts

import { inject, injectable } from 'inversify';
import axios from 'axios';
import Types from 'common/Types';

@injectable()
export default class HttpGateway {
  // Injects the Cache class above via dependency injection.
  @inject(Types.ICache)
  cache!: any; // eslint-disable-line @typescript-eslint/no-explicit-any

  async get(url: string, config: Record<string, unknown>) {
    if (this.cache.has(url)) {
      return this.cache.get(url);
    }

    const result = await axios.get(url, config);
    this.cache.set(url, result);

    return result;
  }

  async post(url: string, config: Record<string, unknown>) {
    return await axios.post(url, config);
  }
}
Code language: TypeScript (typescript)

Immutable

Here’s the diff to make it immutable:

diff --git a/src/gateway/Cache.ts b/src/gateway/Cache.ts
index a039afb..4c65981 100644
--- a/src/gateway/Cache.ts
+++ b/src/gateway/Cache.ts
@@ -4,7 +4,7 @@ export default class Cache {
   store: Store;
 
   constructor(store: Store = {}) {
-    this.store = store;
+    this.store = Object.freeze(store);
   }
 
   get(key: string) {
@@ -19,15 +19,17 @@ export default class Cache {
   }
 
   set(key: string, val: unknown, exp = 86400000, now = new Date().getTime()) {
-    this.store[key] = {
-      val,
-      exp: exp + now,
-    };
-    this.expire();
+    return new Cache({
+      ...this.store,
+      [key]: {
+        val,
+        exp: exp + now,
+      },
+    }).expire();
   }
 
   private expire() {
-    this.store = this.expireMany(this.store, 20);
+    return new Cache(this.expireMany(this.store, 20));
   }
 
   private expired(store: Store, key: string, now = new Date().getTime()) {
diff --git a/src/gateway/HttpGateway.ts b/src/gateway/HttpGateway.ts
index 4fd98f2..cb4e2e2 100644
--- a/src/gateway/HttpGateway.ts
+++ b/src/gateway/HttpGateway.ts
@@ -13,7 +13,7 @@ export default class HttpGateway {
     }
 
     const result = await axios.get(url, config);
-    this.cache.set(url, result);
+    this.cache = this.cache.set(url, result);
 
     return result;
   }

Code language: Diff (diff)

In the 3 questions below, you’ll see how the immutable Cache is simpler.

1. What can change Cache?

Mutable

Almost any class in the app.

That applies in this example…

And most apps with dependency injection or a service container.

Usually, any class can get access to any other class:

class HttpGateway { 
  @inject(Types.Cache)
  cache!: any;Code language: JavaScript (javascript)

So if there’s a bug with Cache, it’ll be hard to tell what changed it.

Almost any class can.

Immutable

Only HttpGateway, the class where it’s stored. 1

It creates a new reference to it:

this.cache = this.cache.set(url, result);Code language: JavaScript (javascript)

The entire app just became simpler, as most of it can’t change Cache now.

The only way to lose this benefit is to rewrite Cache to be mutable.

2. When calling a getter method, could that unexpectedly mutate Cache?

For example, could you add accidentally add code in the body of Cache.get() that deletes a value, like:

delete this.store['example-expired-key'];Code language: JavaScript (javascript)

Mutable

Yes, it’s easy to add side-effects.

They’re how every change happens.

Immutable

No, because getters can’t mutate the object.

There are only:

  1. query methods like .get() that return a value
  2. command methods like .set() that don’t return anything

Neither one can do the other. 2

Even if you added this in .get():

delete this.store['example-expired-key'];Code language: JavaScript (javascript)

…it would do nothing, as its data is immutable from Object.freeze():

class Cache {
  constructor(store: Store = {}) {
    this.store = Object.freeze(store)
  }
}
Code language: TypeScript (typescript)

The only way to change Cache is by adding this.cache = to HttpGateway:

class HttpGateway {
  someMethod() {
    // changes Cache
    this.cache = this.cache.set('foo', 'baz')
Code language: JavaScript (javascript)

If this.cache = isn’t there, it won’t change it:

class HttpGateway {
    someMethod() {
      // can't change Cache
      const something = this.cache.get('foo')
Code language: JavaScript (javascript)

3. How complex can the Cache API get?

If there’s an urgent bug, how easy is it to add yet another parameter to fix it?

Mutable

It’s easy to make the Cache API as complex as you want.

Without Immutability, it could become a complex mess of parameters

Each method could accept many parameters, different from other methods.

Immutable

It can get complex, but you’ll notice right away.

All changes have to go through the constructor, so you’ll see right away when it gets complicated.

It’ll also force you to refactor unrelated code into separate classes or functions.

For example, if you wanted to add parameters to:

  • Delay expiration
  • Back up to the file system
  • Cache to local storage

Those would probably have to be in separate class.

They would add too many constructor parameters:

diff --git a/src/gateway/Cache.ts b/src/gateway/Cache.ts
index 4c65981..1880a2a 100644
--- a/src/gateway/Cache.ts
+++ b/src/gateway/Cache.ts
@@ -2,9 +2,20 @@ type Store = { [k: string]: { val: unknown; exp: number } };
 
 export default class Cache {
   store: Store;
+  delayExpiration?: boolean;
+  fileSystemBackup?: boolean;
+  localStorage?: boolean;
 
-  constructor(store: Store = {}) {
+  constructor(
+    store: Store = {},
+    delayExpiration?: boolean,
+    fileSystemBackup?: boolean,
+    localStorage?: boolean
+  ) {
     this.store = Object.freeze(store);
+    this.delayExpiration = delayExpiration;
+    this.fileSystemBackup = fileSystemBackup;
+    this.localStorage = localStorage;
   }
 
   get(key: string) {
@@ -18,18 +29,36 @@ export default class Cache {
     return !!this.store[key] && !this.expired(this.store, key);
   }
 
-  set(key: string, val: unknown, exp = 86400000, now = new Date().getTime()) {
-    return new Cache({
-      ...this.store,
-      [key]: {
-        val,
-        exp: exp + now,
+  set(
+    key: string,
+    val: unknown,
+    delayExpiration?: boolean,
+    fileSystemBackup?: boolean,
+    localStorage?: boolean,
+    exp = 86400000,
+    now = new Date().getTime()
+  ) {
+    return new Cache(
+      {
+        ...this.store,
+        [key]: {
+          val,
+          exp: exp + now,
+        },
       },
-    }).expire();
+      delayExpiration,
+      fileSystemBackup,
+      localStorage
+    ).expire();
   }
 
   private expire() {
-    return new Cache(this.expireMany(this.store, 20));
+    return new Cache(
+      this.expireMany(this.store, 20),
+      this.delayExpiration,
+      this.fileSystemBackup,
+      this.localStorage
+    );
   }
 
   private expired(store: Store, key: string, now = new Date().getTime()) {


Code language: Diff (diff)

Even 1 immutable class helps

By makingCache immutable…

Much less of the app touches it.

And the parts that do are simpler.

Ideally, almost the whole app would be immutable.

But you’ll see benefits at every step.

  1. Though you could get a similar benefit by removing the singleton scope. Also, you could change from dependency injection to your own composition root. Any object could still mutate any object, but you could see in the composition root which object has a reference to which object. The reason to make this immutable is so you’re more likely to keep this benefit. As long as it’s immutable, it’s impossible to mutate another class’s cache. []
  2. Like Bertrand Meyer’s ideas of commands and queries from Objected-Oriented Software Construction. []

One response to “Immutability Makes Everything it Touches Simpler”

  1. Ryan Kienstra Avatar

    Immutability is a fast way to add functional programming style to your OOP app.

Leave a Reply

Your email address will not be published. Required fields are marked *