Readable JavaScript
TL;DR: For better readability, keep the "cognitive stack" as small as possible. Scroll to the bottom for some practical rules. Read on to see how they derive from the first principle.
Readability is a quality of your code that tells how easy it is for an average developer to read and understand your code.
I want to start with a very simple idea. The less information you have to keep in your head while reading the code, the easier it is to make sense of it.
I like to think of it as a stack. While we read through the code and we stumble across different variable and function declarations (or any important pieces of information really), we push it into our "cognitive stack", and then later when it is no longer needed our brain can pop them out.
The more stuff we have in our stack, the harder it is to reason about the code. And at some point (which happens to me embarrassingly often), we can have it overflowed when we are no longer capable of digesting the information.
So how do you write readable code?
The general idea is to keep the "cognitive stack" size minimal.
In this article, I'll try to show how several readability rules derive from this simple idea.
Refactoring
Have a look at this only slightly exaggerated example,
async function processResults() {
let elm,
urlBase = "https://api.mydomain.com/api/v1/",
href = window.location.href,
query;
const userSession = readSession();
let searchPars = "";
if (href.indexOf(?) > -1) {
searchPars = href.split("?")[1];
}
elm = document.querySelector("body");
if (searchPars) {
const pairs = searchPars.split("&").map((p) => p.split("="));
const q = pairs.find((p) => p[0] === "query");
if (q) {
query = q[1];
} else {
query = "";
}
} else {
query = "";
}
const data = await fetch(
urlBase + `/?query=${query}&token=${userSession.token}`
);
const json = await data.json();
elm.append(
document
.createElement("div")
.append(document.createTextNode(`Query: ${query}`))
);
data.forEach((d) => {
elm.append(
document.createElement("div").append(document.createTextNode(d))
);
});
}
After spending some time reading through this piece of code, you will easily figure out what it does. It reads some parameters from the URL, then loads some data and renders the result on the screen. But you probably also notice that it's far from perfection, and your hands are itchy to refactor it.
Let's try to do that, keeping in mind the "minimal cognitive stack size" principle.
The first thing that you notice is that the elm
variable is declared tens of lines before it is actually used. While we read that piece from top to bottom we have to keep that variable in mind, while there is absolutely no reason for that. That leads us to our first conclusion,
Keep the declarations as close to the first usage as possible.
Plus, it also declared with the let
keyword. Which means we should keep in mind the fact that it can get changed (while it doesn't).
Prefer
const
tolet
, immutable data to mutable.
Let's apply those 2 ideas and see what we'll have.
async function processResults() {
// read the query param from the URL
const href = window.location.href;
let query;
if (href.indexOf("?") > -1) {
const searchPars = href.split("?")[1];
if (searchPars) {
const pairs = searchPars.split("&").map((p) => p.split("="));
const q = pairs.find((p) => p[0] === "query");
if (q) {
query = q[1];
} else {
query = "";
}
} else {
query = "";
}
} else {
query = "";
}
// Load the data
const userSession = readSession();
const data = await fetch(
`https://api.mydomain.com/api/v1/?query=${query}&token=${userSession.token}`
);
const json = await data.json();
// render the results
const elm = document.querySelector("body");
elm.append(
document
.createElement("div")
.append(document.createTextNode(`Query: ${query}`))
);
data.forEach((d) => {
elm.append(
document.createElement("div").append(document.createTextNode(d))
);
});
}
OK, it looks a little bit better now.
But what also happened now that we moved the variable declarations closer to their first usage, is that now the 3 distinct parts have emerged. This means that we can extract those into their own functions.
Why would we want to do that? Because a function has its own lexical scope. And a new lexical scope means that we can "nullify" our stack inside of it.
Prefer breaking down code into the smaller functions
That only holds true unless a function does something to its environment (i.e. modifies variable from the outer scope), and if the result depends only on the passed params. Those kind of functions are called the pure functions. When a function is not pure, we can't empty our stack as we'll need to keep in mind the state of the outer environment.
Prefer the pure functions
async function processResults() {
const query = extractQueryParam(window.location.href);
const data = loadData(query);
render(data);
}
function extractQueryParam(url) {
let query;
if (url.indexOf("?") > -1) {
const searchPars = href.split("?")[1];
if (searchPars) {
const pairs = searchPars.split("&").map((p) => p.split("="));
const q = pairs.find((p) => p[0] === "query");
if (q) {
query = q[1];
} else {
query = "";
}
} else {
query = "";
}
} else {
query = "";
}
return query;
}
async function loadData(query) {
const userSession = readSession();
const data = await fetch(
`https://api.mydomain.com/api/v1/?query=${query}&token=${userSession.token}`
);
const json = await data.json();
return json;
}
function render(data) {
// render the results
const elm = document.querySelector("body");
elm.append(
document
.createElement("div")
.append(document.createTextNode(`Query: ${query}`))
);
data.forEach((d) => {
elm.append(
document.createElement("div").append(document.createTextNode(d))
);
});
}
Pure functions is a God sent. Those are small isolated pieces of code that can be tested, modified and reasoned about separately. What's even better you can name them as well! Naming the pieces of your code is the kind of superpower you can and should use to make your code more readable. Good naming means that a reader may not even have to look what's inside of your function, and save time.
Note how, in this example the higher-order function go first, and then the functions of lower abstraction order (=importance) come next. We can do this in JavaScript because of the hoisting, which basically means that you can declare functions after they being used. Doing so with variables can lead to bugs, but functions fit just nice and let us build our code in a way, where the higher order details come first, and the implementation comes second.
Keep the code responsible for higher-order abstraction higher in the file
Now, lets quickly glimpse at the extractQueryParam
function.
function extractQueryParam(url) {
let query;
if (url.indexOf("?") > -1) {
const searchPars = href.split("?")[1];
if (searchPars) {
const pairs = searchPars.split("&").map((p) => p.split("="));
const q = pairs.find((p) => p[0] === "query");
if (q) {
query = q[1];
} else {
query = "";
}
} else {
query = "";
}
} else {
query = "";
}
return query;
}
It is pure (the output result depends only on the input params), which is nice, but still it doesn't look right. There are several nested if
branches which means that the code leans to the right. What's worse is that at every branch we have have to push more info into our cognitive stack
as our query
variable can be modified at any line. There is a remedy for this.
Prefer returning early
function extractQueryParam(url) {
if (url.indexOf("?") === -1) {
return "";
}
const searchPars = href.split("?")[1];
if (!searchPars) {
return "";
}
const pairs = searchPars.split("&").map((p) => p.split("="));
const q = pairs.find((p) => p[0] === "query");
if (!q) {
return "";
}
return q[1];
}
Now we got rid of the query
variable altogether.
Getting philosophical
It's easy to see that the very same principle holds for any complex system.
Splitting a complex problem into several smaller ones is always beneficial in terms of minimizing the cognitive load. You can't split them randomly thought.
To solve a problem, you need to find the right "mental model" - a way to think about it that keeps your inner "cognitive stack" relatively small. Programming is all about managing complexities, and this is just another side of it.
That's where the principles like "divide and conquer" come from.
The infamous Occam's razor leads to the same idea. Not multiplying the entities without necessity is again keeping your "cognitive stack" as small as possible.
Conclusion
We deduced several readability rules from the very simple principle of keeping our cognitive stack
as small as possible.
Here they are once again to recap.
- Keep the declarations as close to the first usage as possible
- Prefer
const
tolet
, immutable data to mutable. - Break down into the smaller functions
- Prefer using the pure functions
- Keep the code responsible for higher-order abstraction higher in the file
- Prefer returning early
Obviously, that's not an exhaustive list, and you can come up with many others yourself.