Writing simple code has many benefits. By reducing complexity, the code is therefore easier to understand, which in turn makes it easier to read, modify and weed out any bugs that may be lurking within. I think a major indicator of complexity is often related to scope. What I mean is, how much context do I need to have in my brain in order to understand a chunk of code.

There are a lot of common rules we abide by that I believe stem from this principle. For example, we are often told to keep files small, which came from a time when they often looked like this:

#include <stdio.h>

int SOME_STATE;

// ...

int do_something() {
  return SOME_STATE++;
}

Another principle originating during this period was to avoid global state. Both relate to how much context had to be known and understood to also understand a subset of code. In the above case, because a global variable is used in do_something, to understand what that function does we also have to understand every other place in the file that uses SOME_STATE. In this case, the size of the file directly relates to how difficult do_something is to understand.

In a talk titled “Keep It Local”, Chris Krycho talks about this concept of reducing complexity by keeping the required context small. This really clicked for me, as so many of the principles I advocate for on a regular basis stem from this. It’s why this code bothers me:

function validateRequestA(request) {
  request.errors = [];

  if (request.protocol !== "https") {
    request.errors.append("something bad");
  }

  // ...
}

and this code doesn’t:

function validateRequestB(request) {
  const errors = [];

  if (request.protocol !== "https") {
    errors.append("something bad");
  }

  // ...

  return errors;
}

Both functions are performing mutations. The difference? validateRequestA has side effects, meaning that it will affect code outside itself, whereas validateRequestB does not have side effects. Instead, validateRequestB keeps these mutations local to itself.

The problems with validateRequestA are outlined with the following usage:

const request = buildRequest();
validateRequestA(request);
if (request.errors.length === 0) {
  sendRequest(request);
}

To understand what sendRequest will do, we also have to understand how buildRequest and validateRequestA work. Compare this to the following:

const request = buildRequest();
const errors = validateRequestB(request);
if (errors.length === 0) {
  sendRequest(request);
}

Now we just need to understand how buildRequest works to understand what sendRequest will do! We have reduced the context required to understand sendRequest, and as such have reduced complexity!

The unfortunate thing about languages like javascript are that such things are not guaranteed. Even though validateRequestB does not mutate the request, there is nothing stopping it from doing so. Compare this to a language like rust, where the two function calls would look this:

validateRequestA(&mut request);
validateRequestB(&request);

In rust we have mechanisms to communicate whether or not an argument can be mutated, and the compiler will ensure you don’t violate this contract.

As Chris Krycho mentions in his talk, many of the patterns and best practices we learn relate to this principle. Keep files small. Avoid mutations. Avoid goto. Avoid global state. Smaller arguments. Smaller functions. The list goes on. I’d argue that by understanding this underlying principle, we are better equipped to evaluate and improve our own code. We are also better equipped to determine which best practices are actually helping us, and which are potentially redundant1.


  1. For example, I’ve already mentioned that reducing file length has often been recommended to reduce complexity. This holds true in OO languages, where mutations to the instance often take place throughout the file, but in pure functional languages this relation breaks down. The context of a pure function is itself, the functions it calls, and the arguments. The length of the file no longer contributes to complexity. ↩︎