DEV Community

Artem Sapegin
Artem Sapegin

Posted on • Edited on • Originally published at blog.sapegin.me

Washing your code: avoid loops

You’re reading an excerpt from my book on clean code, “Washing your code.” Available as PDF, EPUB, and as a paperback and Kindle edition. Get your copy now.

Traditional loops, like for or while, are too low-level for common tasks. They are verbose and prone to off-by-one error. You have to manage the index variable yourself, and I always make typos with lenght. They don’t have any particular semantic value except that you’re doing some operation probably more than once.

Replacing loops with array methods

Modern languages have better ways to express iterative operations. JavaScript has may useful methods to transform and iterate over arrays, like .map() or .find().

For example, let’s convert an array of strings to kebab-case with a for loop:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
for (let i = 0; i < names.length; i++) {
  names[i] = _.kebabCase(names[i]);
}
Enter fullscreen mode Exit fullscreen mode

And now with the .map() method:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
const kebabNames = names.map(name => _.kebabCase(name));
Enter fullscreen mode Exit fullscreen mode

We can shorten it even more if our processing function accepts only one argument, and kebabCase from Lodash does:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
const kebabNames = names.map(_.kebabCase);
Enter fullscreen mode Exit fullscreen mode

But this may be a bit less readable than the expanded version, because we don’t see what exactly we’re passing to a function. ECMAScript 6’s arrow functions made callbacks shorter and less cluttered, compared to the old anonymous function syntax:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
const kebabNames = names.map(function(name) {
  return _.kebabCase(name);
});
Enter fullscreen mode Exit fullscreen mode

Or let’s find an element in an array with a for loop:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
let foundName;
for (let i = 0; i < names.length; i++) {
  if (names[i].startsWith('B')) {
    foundName = names[i];
    break;
  }
}
Enter fullscreen mode Exit fullscreen mode

And now with the .find() method:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
const foundName = names.find(name => name.startsWith('B'));
Enter fullscreen mode Exit fullscreen mode

In both cases I much prefer versions with array methods than with for loops. They are shorter and we’re not wasting half the code on iteration mechanics.

Implied semantics of array methods

Array methods aren’t just shorter and more readable; each method has its own clear semantic:

  • .map() says we’re transforming an array into another array with the same number of elements;
  • .find() says we’re finding a single element in an array;
  • .some() says we’re testing that the condition is true for some array elements;
  • .every() says we’re testing that the condition is true for every array element.

Traditional loops don’t help with understanding what the code is doing until you read the whole thing.

We’re separating the “what” (our data) from the “how” (how to loop over it). More than that, with array methods we only need to worry about our data, which we’re passing in as a callback function.

When you use array methods for all simple cases, traditional loops signal to the code reader that something unusual is going on. And that’s good: you can reserve brain resources for better understanding the unusual, more complex cases.

Also don’t use generic array methods like .map() or .forEach() when more specialized array methods would work, and don’t use .forEach() when .map() would work:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
const kebabNames = [];
names.forEach(name => {
  kebabNames.push(_.kebabCase(name));
});
Enter fullscreen mode Exit fullscreen mode

This is a more cryptic and less semantic implementation of .map(), so better use .map() directly like we did above:

const names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
const kebabNames = names.map(name => _.kebabCase(name));
Enter fullscreen mode Exit fullscreen mode

This version is much easier to read because we know that the .map() method transforms an array by keeping the same number of items. And unlike .forEach(), it doesn’t require a custom implementation nor mutate an output array. Also the callback function is now pure: it doesn’t access any variables in the parent function, only function arguments.

Dealing with side effects

Side effects make code harder to understand because you can no longer treat a function as a black box: a function with side effects doesn’t just transform input to output, but can affect the environment in unpredictable ways. Functions with side effects are also hard to test because you’ll need to recreate the environment before each test and verify it after.

All array methods mentioned in the previous section, except .forEach(), imply that they don’t have side effects, and that only the return value is used. Introducing any side effects into these methods would make code easy to misread since readers wouldn’t expect to see side effects.

.forEach() doesn’t return any value, and that’s the right choice for handling side effects when you really need them:

errors.forEach(error => {
  console.error(error);
});
Enter fullscreen mode Exit fullscreen mode

for of loop is even better:

  • it doesn’t have any of the problems of regular for loops, mentioned in the beginning of this chapter;
  • we can avoid reassignments and mutations, since we don’t have a return value;
  • it has clear semantics of iteration over all array elements, since we can’t manipulate the number of iterations, like in a regular for loop. (Well, almost, we can abort the loops with break.)

Let’s rewrite our example using for of loop:

for (const error of errors) {
  console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

Sometimes loops aren’t so bad

Array methods aren’t always better than loops. For example, a .reduce() method often makes code less readable than a regular loop.

Let’s look at this code:

const tableData = [];
if (props.item && props.item.details) {
  for (const client of props.item.details.clients) {
    for (const config of client.errorConfigurations) {
      tableData.push({
        errorMessage: config.error.message,
        errorLevel: config.error.level,
        usedIn: client.client.name
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

My first reaction would be to rewrite it with .reduce() to avoid loops:

const tableData =
  props.item &&
  props.item.details &&
  props.item.details.clients.reduce(
    (acc, client) => [
      ...acc,
      ...client.errorConfigurations.reduce(
        (inner, config) => [
          ...inner,
          {
            errorMessage: config.error.message,
            errorLevel: config.error.level,
            usedIn: client.client.name
          }
        ],
        []
      )
    ],
    []
  );
Enter fullscreen mode Exit fullscreen mode

But is it really more readable?

After a cup of coffee and a chat with a colleague, I’ve ended up with a much cleaner code:

const tableData =
  props.item &&
  props.item.details &&
  props.item.details.clients.reduce((acc, client) =>
    acc.concat(
      ...client.errorConfigurations.map(config => ({
        errorMessage: config.error.message,
        errorLevel: config.error.level,
        usedIn: client.client.name
      }))
    ),
    []
  );
Enter fullscreen mode Exit fullscreen mode

I think I still prefer the double for version, but I’ll be happy with both versions, the original and the second rewrite, if I had to review such code.

(Though tableData is a really bad variable name.)

Iterating over objects

There are many ways to iterate over objects in JavaScript. I equally dislike them all, so it’s hard to choose the best one. Unfortunately there’s no .map() for objects, though Lodash does have three methods for object iteration, so it’s a good option if you’re already using Lodash in your project.

const allNames = {
  hobbits: ['Bilbo', 'Frodo'],
  dwarfs: ['Fili', 'Kili']
};
const kebabNames = _.mapValues(allNames, names =>
  names.map(name => _.kebabCase(name))
);
Enter fullscreen mode Exit fullscreen mode

If you don’t need the result as an object, like in the example above, Object.keys(), Object.values() and Object.entries() are also good:

const allNames = {
  hobbits: ['Bilbo', 'Frodo'],
  dwarfs: ['Fili', 'Kili']
};
Object.keys(allNames).forEach(race =>
  console.log(race, '->', allNames[race])
);
Enter fullscreen mode Exit fullscreen mode

Or:

const allNames = {
  hobbits: ['Bilbo', 'Frodo'],
  dwarfs: ['Fili', 'Kili']
};
Object.entries(allNames).forEach(([race, value]) =>
  console.log(race, '->', names)
);
Enter fullscreen mode Exit fullscreen mode

I don’t have a strong preference between them. Object.entries() has more verbose syntax, but if you use the value (names in the example above) more than once, the code would be cleaner than Object.keys(), where you’d have to write allNames[race] every time or cache this value into a variable at the beginning of the callback function.

If I stopped here, I’d be lying to you. Most of the articles about iteration over objects have examples with console.log(), but in reality you’d often want to convert an object to another data structure, like in the example with _.mapValues() above. And that’s where things start getting uglier.

Let’s rewrite our example using .reduce():

const kebabNames = Object.entries(allNames).reduce(
  (newNames, [race, names]) => {
    newNames[race] = names.map(name => _.kebabCase(name));
    return newNames;
  },
  {}
);
Enter fullscreen mode Exit fullscreen mode

With .forEach():

const allNames = {
  hobbits: ['Bilbo', 'Frodo'],
  dwarfs: ['Fili', 'Kili']
};
const kebabNames = {};
Object.entries(allNames).forEach(([race, names]) => {
  kebabNames[race] = names.map(name => name.toLowerCase());
});
Enter fullscreen mode Exit fullscreen mode

And with a loop:

const kebabNames = {};
for (let [race, names] of Object.entries(allNames)) {
  kebabNames[race] = names.map(name => name.toLowerCase());
}
Enter fullscreen mode Exit fullscreen mode

And again .reduce() is the least readable option.

In later chapters I’ll urge you to avoid not only loops but also reassigning variables and mutation. Like loops, they often lead to poor code readability, but sometimes they are the best choice.

But aren’t array methods slow?

You may think that using functions is slower than loops, and likely it is. But in reality it doesn’t matter unless you’re working with millions of items.

Modern JavaScript engines are very fast and optimized for popular code patterns. Back in the day we used to write loops like this, because checking the array length on every iteration was too slow:

var names = ['Bilbo Baggins', 'Gandalf', 'Gollum'];
for (var i = 0, namesLength = names.length; i < namesLength; i++) {
  names[i] = _.kebabCase(names[i]);
}
Enter fullscreen mode Exit fullscreen mode

It’s not slow anymore. And there are other examples where engines optimize for simpler code patterns and make manual optimization unnecessary. In any case, you should measure performance to know what to optimize, and whether your changes really make code faster in all important browsers and environments.

Also .every(), .some(), .find() and .findIndex() will short circuit, meaning they won’t iterate over more array elements than necessary.


Start thinking about:

  • Replacing loops with array methods, like .map() or .filter().
  • Avoiding side effects in functions.

If you have any feedback, mastodon me, tweet me, open an issue on GitHub, or email me at artem@sapegin.ru. Get your copy.

Top comments (5)

Collapse
 
itsjzt profile image
Saurabh Sharma • Edited

while I don't find for of loops less readable than any functional array transformation.

I use functional methods heavily for array transformations or when I use React.

Other than that I find loops more readable when logic is longer than 3, 4 line or contains many if/else.

In async stuff loops are much better because in map/reduce you need to await the map/reduce and make the inner function async and also await inside it.

for ex:

async function getUsers (userIds) {
  const users = userIds.map(async userId => {
    const response = await fetch(`/api/users/${userId}`);
    return response.json();
  });
  // ... do some stuff
  return users;
}
Collapse
 
samuraiseoul profile image
Sophie The Lionhart

The reduce in your example is bad because it is not a reduce, its should be a map, reduce is for reducing the entirety to one value normally to sum or average or something.

if (!props.item && props.item.details) { return; }
props.item.details.clients.map(function(client) {
    return client.errorConfigurations.map(function(config){
        usedIn : client.client.name,
        errorMessage : config.error.message,
        errorLevel: config.error.level
    });
}).flat(); // I did not test this at all, I take no responsibility for anything

Also using the function(){} syntax is still useful if you give the function a name, helps give it some extra semantics over the use of arrow functions.

Collapse
 
qm3ster profile image
Mihail Malo
const tableData =
  props.item &&
  props.item.details &&
  props.item.details.clients.reduce((acc, client) =>
    acc.concat(
      ...client.errorConfigurations.map(config => ({
        errorMessage: config.error.message,
        errorLevel: config.error.level,
        usedIn: client.client.name
      }))
    )
  );

is missing the initial value for reduce, such as a [].
And like @samuraiseoul mentioned, this is a case for a flatMap.

const tableData = props.item?.details?.clients.flatMap(
  ({ client, errorConfigurations }) =>
    errorConfigurations.map(({ error }) => ({
      errorMessage: error.message,
      errorLevel: error.level,
      usedIn: client.name
    }))
)

If you don't have your own flatMap yet, store bought is fine:

const flatMap = <T, R>(fn: (x: T) => R[], arr: T[]) => {
  const out: T[] = []
  const { push } = Array.prototype
  for (const x of arr) push.apply(out, fn(x))
}

JK, don't use this one, it's probably not very good.

Collapse
 
sapegin profile image
Artem Sapegin

Thanks for pointing the missing initializer, fixing!

Collapse
 
qm3ster profile image
Mihail Malo

Object#fromEntries() to the rescue:

const allNames = {
  hobbits: ['Bilbo', 'Frodo'],
  dwarfs: ['Fili', 'Kili']
};
const kebabNames = Object.fromEntries(
  Object.entries(allNames).map(([race, names]) => [
    race,
    names.map(name => name.toLowerCase())
  ])
)