The sad sad reality of JavaScript and most programming language out there is that there is little to no guarantee on what a function can do.

Little Space Guarantee

The amount of value that a function can reach and mutate is humongous. That is because the object graph is super connected. Think of the famous “gorilla and banana” problem.

I think the lack of reusability comes in object-oriented languages, not functional languages. Because the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. – Joe Armstrong

I would distinguish between two flavors of mutation. First, the bad: mutation of arguments. But at least it does not break referential transparency. And the caller can still checkout the argument after calling the function to see what’s up. I’m guilty of using those and even getting comfortable doing so. It is neat for implementing state transition functions without having to reconstruct an entire new state. I mean by state transition functions, functions of type: (state, input) -> (state, output). That is pretty much any method you would find on objects in OOP.

// state transition function:
export const createGen = (seed) => ({ seed });
export const random = (gen, min, max) => {
  gen.seed = computeNext(gen.seed);
  return randomFromSeed(gen.seed, min, max);
};

// With OOP syntactic sugar:
export class Gen {
  constructor (seed) {
    this.seed = seed;
  }
  random (min, max) {
    this.seed = computeNext(this.seed);
    return randomFromSeed(this.seed, min, max); 
  } 
};

Second, the ugly: mutation of values in free variables. This is worse because it introduces implicit state and breaks referential transparency. The caller has no idea what is going on. The worse you can do is mutating values reached by global variables. People will tell you to never do that. But I don’t care, sometimes you have to do it. I do dynamic program analysis for a living, and sometimes I have to do this abomination even if it always bites me in the ass. This kind of code is hard to maintain and often requires to dive deep in it to understand the link between seemingly unrelated parts.

// Original version:
const square = (x) => x * x;
// Instrumented version:
const squareInstrumented = (x) => {
  LOG.push("begin-square");
  try {
    return x * x;
  } finally {
    LOG.push("end-square");
  }
}; 

No Time Guarantee

Bloody hell, mutations can even happen after the function returned. Now the caller is getting real paranoid. It is not sufficient to check whatever mess the function did right after it returned. But it could mess things later! I struggled with this recently. I needed to record some events and flush them. I ended up doing something like this:

// hook.mjs
import process from "node:process";
import { createHook } from 'node:async_hooks';
export const hook = (buffer) => {
  process.on("uncaughtErrorMonitor", (error) => {
    buffer.push({
      type: "error",
      error,
    });
  });
  createHook({
    before: (id) => {
      buffer.push({
        type: "before",
        id,
      });
    },
    after: (id) => {
      buffer.push({
        type: "after",
        id,
      });
    },
  }).enable();
}
// flush.mjs
import { Socket } from "node:net";
export const flush = (buffer) => {
  const socket = connect("localhost:8080");
  setInterval(() => {
    socket.write(JSON.stringify(buffer));
    buffer.length = 0;
  }, 1000);
};
// main.mjs
import { hook } from "./hook.mjs";
import { flush } from "./flush.mjs";
const buffer = [];
hook(buffer);
flush(buffer);

I think this is bad code because the interaction between hook.mjs and flush.mjs is not immediately apparent from main.mjs. Adapting hook.mjs and flush.mjs to use callbacks can make this interaction explicit.

// main.mjs
import { hook } from "./hook.mjs";
import { flush } from "./flush.mjs";
const buffer = [];
hook((event) => {
  buffer.push(event);
});
flush(() => buffer.splice(0, buffer.length));

Maybe we can write saner JavaScript code by following these two rules:

  • Arguments can only be mutated synchronously.
  • Asynchronous mutations can only happen with explicit callbacks.