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
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)
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:
- query methods like .get() that return a value
- 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.
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.
- 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. [↩]
- Like Bertrand Meyer’s ideas of commands and queries from Objected-Oriented Software Construction. [↩]
Leave a Reply