DEV Community

Cover image for Human Readable JavaScript
Laurie
Laurie

Posted on • Originally published at tenmilesquare.com

Human Readable JavaScript

For a long time, humans needed to "speak" like machines in order to communicate with them. And that's still true, we still have the need for people who work in assembly and other low-level languages. But for many of us, these complexities are abstracted away. Our job, is to focus on what is readable for humans and let the machines interpret our code.

This consideration is never more apparent than a situation in which identical code can be written in numerous ways. So today, I want to talk less about how something works, and more about how it reads. There is another post in here somewhere about functional JavaScript, but let's assume we're talking about map.

map is a function available for arrays in JavaScript. Think of it as for each. It takes a function as an argument and runs each element in the array through that function. The difference is that it doesn't alter the original array at all. The result is a new array.

Example

const arr = [1,2,3]
let multipliedByTwo = arr.map(el => el*2)
// multipledByTwo is [2,4,6]
Enter fullscreen mode Exit fullscreen mode

Ok, so we know what map does. But look at the code snippet above. An incredibly terse function that multiplies a variable by two.

So let's take a look at all the different ways we could write that same logic.

Optional Parentheses

The first optional addition we can make is to add parentheses to the parameter definition of the internal function. This makes that piece of code start to look more like a typical function definition.

const arr = [1,2,3]
let multipliedByTwo = arr.map((el) => el*2)
Enter fullscreen mode Exit fullscreen mode

What's interesting about this is that the only reason we don't need them is because we're only passing one argument.

const arr = [1,2,3]
let multipliedByTwo = arr.map((el, index) => el*2)
Enter fullscreen mode Exit fullscreen mode

In cases where we pass more than one argument, the parens are not optional. Our example is map, if it were reduce we would always use the parentheses.

So let's take stock for a moment. Do we lose anything by adding the parentheses? Do we gain anything? We're adding two characters, what information does that convey? These are the things we need to ask ourselves as we develop code for our teammates and future selves to maintain and read.

Curly braces and return

We can go a step further with making that internal function adhere to official function syntax. Doing so requires curly braces and the return keyword.

const arr = [1,2,3]
let multipliedByTwo = arr.map((el) => { return el*2})
Enter fullscreen mode Exit fullscreen mode

How do we feel about this code now? It certainly reads more clearly as a function. Do the braces and return add more bulk? Does our view of this change depending on the logic being returned?

As it turns out, this is again non-optional if our function logic is more than one line.

const arr = [1,2,3]
let multipliedByTwo = arr.map(
(el) => { 
  if(el%2 === 0) {
      return el*2
  } else {
      return el+1
  }
})
Enter fullscreen mode Exit fullscreen mode

Interesting. Does our opinion of the extra characters change based on the use case? What does that mean for consistency throughout our code?

Use a separate function

As we know and have seen, map takes a function as an argument and passes each element in our array into it. Perhaps we could, or should, define our internal logic outside of the map. As it stands, it looks a bit like pyramid code.

const arr = [1,2,3]

const timesTwo = (el) => el*2

let multipliedByTwo = arr.map((el) => timesTwo(el))
Enter fullscreen mode Exit fullscreen mode

What do we think? Realistically it's almost the same number of characters as the original version. But what about our example from above with more complex logic?

const arr = [1,2,3]

const timesTwoOrPlusOne = (el) => { 
  if(el%2 === 0) {
      return el*2
  } else {
      return el+1
  }
}

let multipliedByTwo = arr.map((el) => timesTwoOrPlusOne(el))
Enter fullscreen mode Exit fullscreen mode

Did this change your view? Or does it look cluttered and repetitive?

Just a function

Functional programming is an interesting paradigm. In part because of the way it allows us to write code. Again we're reminded that map takes a function as an argument. So why not give it a function.

const arr = [1,2,3]

const timesTwo = (el) => el*2

let multipliedByTwo = arr.map(timesTwo)
Enter fullscreen mode Exit fullscreen mode

Yes, this is valid. map knows to pass the element it gets to the function and use the result. We can get even more in the weeds by determining what form our timesTwo function could take. Right now it's a terse one-liner.

And note that map is really smart. We can pass the same function even if that function now uses both the element and the index to arrive at a return value!

const arr = [1,2,3]

const timesTwoPlusIndex = (el, index) => (el*2) + index

let multipliedByTwo = arr.map(timesTwoPlusIndex)
Enter fullscreen mode Exit fullscreen mode

Does this seem readable? multipledByTwo is certainly pleasant to read, but where is timesTwoPlusIndex located in our codebase? Is it hard to track down? If someone is looking at this for the first time do they know it's a function? Or do they assume it's an object or array variable?

Functions are objects in JavaScript, but ignore that duplication for the moment.

How do we determine what is readable

There is no one size fits all syntax. Who is your audience? Polyglots or JavaScript experts? Who is maintaining your code? How many people work in this codebase? All of these things matter.

It entirely depends on the use case, and consistency is important. However, seeing all the different representations of the same functionality is eye-opening. All of these examples will be built into the same minified code. So the decision for us, as developers, is based on human readability. It's completely absent of machine performance and functionality considerations.

I've posed a lot of questions and not a lot of answers. I have my own opinions but would love to hear yours. Which of these are the most readable? Are there versions you prefer to write? Let's discuss it below!

Top comments (46)

Collapse
 
jacobmgevans profile image
Jacob Evans

Love the point free (tacit programming) put in there. I am getting more into functional programming but have to consider my peers and juniors coming in when writing my code... Usually, I ask myself "could I have understood or figured this out when I first finished my coding Bootcamp...? If no I refactor."

Collapse
 
_ezell_ profile image
Ezell Frazier

That's a really good principle to work by.

Refactor only if:

  • It has bugs
  • A junior dev would be stressed out trying to read it
Collapse
 
pedro00dk profile image
Pedro Henrique • Edited

I think you should fix it, not refactor if it has bugs.
By definition, refactoring does not change behavior.

Thread Thread
 
_ezell_ profile image
Ezell Frazier

True, but when we encounter bugs, don't we ask what caused the bug? Too many moving parts? Unclear data-flow? In the postmortem of a bug-fix, the topic of refactoring doesn't come up?

Thread Thread
 
jacobmgevans profile image
Jacob Evans

Well said Ezell, I agree that refactoring during a bugfix rewrite is a good idea for sure!

Collapse
 
laurieontech profile image
Laurie

What a great barometer!

Do you think you're losing sight of that level at all? That's always my fear. That I overestimate my past self.

Collapse
 
jacobmgevans profile image
Jacob Evans

Sometimes I do. I generally will have mentees or people I know learning code take look. If they have trouble understanding it ill have them describe why to be sure it's not just a knowledge issue but an over-complicated issue. ๐Ÿ˜† So far it seems somewhat successful. I still write my own esoteric code on personal stuff lmao ๐Ÿคฃ๐Ÿ˜…

Collapse
 
gypsydave5 profile image
David Wickes • Edited

The refactor from

const arr = [1,2,3]

const timesTwo = (el) => el*2

let multipliedByTwo = arr.map((el) => timesTwo(el))

to

const arr = [1,2,3]

const timesTwoPlusIndex = (el, index) => (el*2) + index

let multipliedByTwo = arr.map(timesTwoPlusIndex)

is one of my favourites.

Interestingly (well, interesting to me anyway), it's an example of ฮท-conversion (eta-conversion), one of the three basic reduction steps in the Lambda calculus.

Collapse
 
robocel profile image
Rob Ocel • Edited

The most common pitfall though with this approach is when, for example, converting an array of strings into integers.

["1", "2", "3"].map(parseInt) = [1, NaN, NaN]

["1", "2", "3"].map(x => parseInt(x)) = [1, 2, 3]

The reason for this is that parseInt actually takes 2 arguments (string and radix). Map accepts methods that take up to 3 arguments (value, index, and array). So, when passed to map directly, string is getting value (what we expect!), but index is being passed as the radix. Meaning, you try to parse the 0th element in a natural way (base 10), then you try to parse the 1st element as base one (which it's not a valid base 1 number, so NaN), parse the 2nd element as base two (again, 3 is not valid base 2, so NaN), and so on...

I've been bitten by this bug quite a few times. When using map, bypassing the anonymous function and passing a named function should generally only be used if the function takes a single argument.

Collapse
 
puiutucutu profile image
puiu • Edited

The default behaviour of Array.map is unintuitive given that it returns the index and the original array as the second and third arguments to the callback function respectively.

I would approach the parseInt problem by writing a map function that takes two args, supplied one at a time (to facilitate partial application).

  • the first arg, a function f that will be supplied only one value at a time, that is, the current iterated value
  • the second arg, a 1-dimensional array of values to apply the function f on

It may appear complicated seeing it for the first time, but come back to the example and mull it over and it will start to click.

/* 
// alternative map implementation
const mapAlt = f => xs => Array.prototype.map.call(xs, currentValue => f (currentValue));


// unterse
function map(f) {
  return function(xs) {
    return xs.map(function(currentValue, index, originalArray) {
      return f(currentValue);
    });
  };
}
*/

const map = f => xs => xs.map(function(currentValue, index, originalArray) {
  return f (currentValue);
});


const xs = [1, 2, 3, 4];
const multiplier = x => x * 6;
const multiplied = map (multiplier) (xs);

const ys = ["1", "2", "3", "4"];
const parser = x => parseInt(x);
const parsed = map (parser) (ys);

console.log("xs:", xs); //=> [1, 2, 3, 4]
console.log("xs multiplied:", multiplied); //=> [6, 12, 18, 24]
console.log("ys:", ys); //=> ["1", "2", "3", "4"]
console.log("ys parsed:", parsed); //=> [1, 2, 3, 4]
Thread Thread
 
crosseye profile image
Scott Sauyet

But why include index and originalArray parameters when you don't use them?

For that matter, why not make this point-free?

const map = (f) => (xs) => xs .map ((x) => f(x))

(... and around in circles we go!)

Thread Thread
 
puiutucutu profile image
puiu

That's true, they are superfluous - I left those other args there to make it clearer how the args are moving around.

Collapse
 
salembeats profile image
Cuyler Stuwe

Though it might be a distraction from the example... You could put Number there in place of parseInt.

Collapse
 
laurieontech profile image
Laurie

That's a good one! Thanks for pointing it out.

Collapse
 
laurieontech profile image
Laurie

Haha it is! I don't think I'd made that connection before.

Collapse
 
gypsydave5 profile image
David Wickes

I know! I think it's maybe the only place I've ever found a practical use for lambda calculus!

Collapse
 
jeastham1993 profile image
James Eastham • Edited

Love this Laurie!

I'm a .NET developer, but always make it my aim to make code read like a story. Things like

var settings = new Settings(this.Configuration)

vs

var settings = Settings.LoadFrom(this.Configuration)

Simple example, but making your code read like a story makes it so much easier to pick up coming back to it.

I think short functions and readable code are fundamentals for code that others can pick up.

Collapse
 
laurieontech profile image
Laurie

Love that!

Collapse
 
pierreturnbull profile image
Pierre Turnbull • Edited

I think it's important not to simplify the code at a point where you avoid using functionalities which are useful but less readable for a newbie.

For example instead of the following:

    let multipliedByTwo = arr.map(
        (el) => { 
            if(el%2 === 0) {
            return el*2
        } else {
            return el+1
        }
    })

I would prefer using ternary, and no optional syntax characters such as parenthesis or curly braces:

let multipliedByTwo = arr.map(el => el % 2 === 0 ? el * 2 : el + 1)

It may seem less approachable for someone who is not used to ternay, arrow functions and the absence of unnecessary syntax characters, but when used to it, it's actually better to have such a one line operation rather than 8 line of basic if...else.

In other words, I prefer to write a code that requires the maintainer to raise his level of JavaScript understanding but enables code to be shorter, rather than making super simple but verbose JavaScript (I hate to search for bits of code in hundred-lines-long files).

Collapse
 
codetroll profile image
Claus Paludan

Yeah - I wouldn't want to have to figure out what goes wrong in the middle of the night when they call with production issues.

And it is not unnecessary syntax characters except for the computer - for humans, it adds readability and understanding.

And your stuff will break down when the new guy is covering everything while you are on vacation.

Collapse
 
laurieontech profile image
Laurie

Interesting. I tend to think of one liners as less readable in most cases. But itโ€™s an interesting perspective!

Collapse
 
cher profile image
Cher

Developers in general agree with you. Most one liners beyond a standard if/else ternary are caught by default linting configs. If someone has to translate the code you write, it's meant for a machine. Given that this already happens once it hits an engine, it is generally not wise to write code in this manner.

Thread Thread
 
laurieontech profile image
Laurie

โ€œCode is for humans to read and machines to interpretโ€! Still donโ€™t know who said it first, but itโ€™s a great quote.

Collapse
 
rhysbrettbowen profile image
Rhys Brett-Bowen

How about splitting things up?

timesTwo = e => e * 2;
add = (a, b) => a + b;

const result = arr
.map(timesTwo)
.map(add) // add index

putting maps on separate lines helps people see it broken in to steps

adding in a comment on the "add" helps because most maps don't usually use index.

you could do .map(addIndex) but I don't like this as the original function can add any two things, not just index

or .map((a, i) => add(a, i)) but that creates another function

Collapse
 
_ezell_ profile image
Ezell Frazier • Edited

Hi Laurie, great question.

I don't have a 'one-shoe-fits-all' approach to writing the most readable code all the time. At one point I wished for that, but I'm understanding writing code is better off not that way.

The last pattern described particularly useful when the associated callback function for array.map is so long, I forget that it's a callback. In that case I may have something like this:

// Either import it or placed elsewhere in the same file if not used anywhere else
import {crazyLongCallback} from './crazyCallbacks'

const bigDataList = [...]
const parsedDataList = bigDataList.map(crazyLongCallback) 

So I find it useful when discovering different design patterns and ways to handle problems. I tend to gravitate toward the solution in which I hope me I and my colleagues can read after a day, week, or month's time.

Thanks for the read!

Collapse
 
laurieontech profile image
Laurie

That's awesome. Do you find yourself staying consistent in the same codebase?

Collapse
 
_ezell_ profile image
Ezell Frazier

Yep, but I just thought of another question. Does the length of a code-block have a big impact on readability? I'd imagine this isn't the case for everyone.

Thread Thread
 
laurieontech profile image
Laurie

I don't know. Does it? I'd argue that it absolutely does. But you can shorten a code block and make it less readable at the same time. It matters how you do these things.

Collapse
 
emnudge profile image
EmNudge

As it turns out, this is again non-optional if our function logic is more than one line.

I know this isn't at all what you meant, but I thought I'd throw this in the comments for fun.

You can many times write code that would normally need curly braces without them by using parentheses expressions. You can't use statements (like creating new variables or if, else, for, etc), but you can call a few functions and return the last value.

const newArr = myArr.map(x => (console.log(x), changeX(x));

Just a little tidbit I thought I'd throw in. Excellent article nevertheless!

Collapse
 
juliang profile image
Julian Garamendy • Edited

excellent article!
brb I'm going to change my prettier config:
"arrowParens": "always"

Collapse
 
skattspa profile image
Skattspa

Honestly and acording to my experience:
Better less lines and one comentary line than a lot of curly braces and separates functions.
And second rule:
If a function is more than 10 lines, you should do two.
That is my opinion. Like an ass, everybody has one.

Collapse
 
laurieontech profile image
Laurie

Everyone can have an opinion, it's good to talk about these things. I don't go by function lines, but by functionality. A function should do one thing. That normally keeps it short.

Collapse
 
skattspa profile image
Skattspa

I like to talk about this when people is like you, giving examples. I was trying to make a point about the developers that likes to code in one way and push everybody on that way...only because the guy/girl like it.
Not even with coding reasons, performance or teamworking timing examples on a project.
Maybe now its more clear the point. I hope so. No offense about the ass, its just a funny sentence for me.

Collapse
 
loganthemanster profile image
Dawid

Very nice article and I love the conclusion "There is no one size fits all syntax". I probably would have written the first now version because I like the "functional programming"-mindset it applies.

For people who struggle with arrow functions I would have included the plain "function" version, too:

const arr = [1,2,3]
let multipliedByTwo = arr.map(function(el) {
    return el*2;
})

I had a pretty hard time in grasping lambdas when they were introduced in Java 8 and it was really irking me that most of the tutorials about this topic did not include a 1:1 non-lambda "translation" :/