We don't need control statements

10 min read

A little story

It was the first day of my last year of high school, more than ten years ago. The new programming teacher arrived and stood silent for a second, and then he started the lesson:

This year we will create a state machine with persistence using C++. This state machine will be a light-bulb that can be turned on, or off.

My classmates and I looked at each other, thinking, "ok, that will be easy as pie," and then he dropped the bomb:

There's a catch: You'll not be allowed to use if or for for it.

Now the entire class was confused. Flow control is one of the first things we all learn as programmers. From day one, our new teacher wanted to teach us that we shouldn't think about conditions as if, repetitions as for, and so on. So instead of translating logic to code, we should think abstractly and logically, worrying about implementation details later.

JavaScript statements

JavaScript has quite a bunch of control statements:

  • if/else.
  • for/of/in.
  • while.
  • do/while.
  • switch/case.
  • try/catch.

We'll go through that list and learn about some of the alternatives we have, which are generally safer and cleaner from my point of view. Let's begin!

Conditions (if/switch)

Let's take this simple example as a starting point:

const welcomeMessage = ({ admin }) => {
	let message;
	if (admin) {
		message = "Welcome, administrator!";
	}
	return message;
};

So we have a function welcomeMessage which takes a user object and returns a message which depends on the user's role. Now, because this if is quite simple, we might spot already that this has an issue, but JavaScript itself doesn't give us any error. We don't have a default value for that message, so we need to do something like this:

const welcomeMessage = ({ admin }) => {
	let message = "Welcome, user";
	if (admin) {
		message = "Welcome, administrator!";
	}
	return message;
};

// Or

const welcomeMessage = ({ admin }) => {
	let message;
	if (admin) {
		message = "Welcome, administrator!";
	} else {
		message = "Welcome, user";
	}
	return message;
};

As I said in the introduction, we don't need if, for this, we can use a ternary instead. A ternary has the following structure:

boolean ? valueForTrue : valueForFalse

So we can change welcomeMessage to be like this:

const welcomeMessage = ({ admin }) =>
	admin ? "Welcome, administrator!" : "Welcome, user";

// Or

const welcomeMessage = ({ admin }) =>
	`Welcome, ${admin ? "administrator" : "user"}!`;

Ternaries have three advantages over ifs:

  1. They force us to cover all the logic branches. This means "every if has a mandatory else."
  2. They reduce the amount of code drastically (we just need a ? and a :).
  3. They force us to use conditional values instead of conditional blocks, which results in us moving logic from if blocks to their own little functions.

The main argument against ternaries is that they become hard to read if we have several levels of nested ifs (ifs inside an ifs), and that's true, but I see that as yet another advantage. If we need to nest logic, that means that we need to move that logic away. So, let's have yet another example of this:

const welcomeMessage = ({ canMod, role }) =>
	`Welcome, ${
		canMod ? (role === ADMIN ? "administrator" : "moderator") : "user"
	}!`;

That became hard to read quite quickly, but that means that we need to move some logic away from welcomeMessage, so we need to do something like this:

const roleText = role => (role === ADMIN ? "administrator" : "moderator");

const welcomeMessage = ({ canMod, role }) =>
	`Welcome, ${canMod ? roleText(role) : "user"}!`;

We covered if already, but what about switch? Again, we can use a combination of plain objects and the ?? operator, so we go from this:

const welcomeMessage = ({ role }) => {
	switch (role) {
		case ADMIN:
			return "Welcome, administrator!";
		case MOD:
			return "Welcome, moderator!";
		default:
			return "Welcome, user!";
	}
};

To this:

const roleToText = role =>
	({
		[ADMIN]: "administrator",
		[MOD]: "moderator",
	}[role] ?? "user");

const welcomeMessage = ({ role }) => `Welcome, ${roleToText(role)}!`;

For those not familiar with the ?? operator, it works like this:

possiblyNullishValue ?? defaultValue

possiblyNullishValue can be either a value or "nullish" (null or undefined). If it is nullish, then we use defaultValue; if it isn't nullish, then we use the value itself. Previous to this, we used to use ||, but that goes to the default for all falsy values (0, 0n, null, undefined, false, NaN, and ""), and we don't want that.

Error handling (try/catch).

When we want to run something that might throw an error, we wrap it with a try/catch, as follows:

const safeJSONParse = value => {
	let parsed;
	try {
		parsed = JSON.parse(value);
	} catch {
		// Leave `parsed` `undefined` if parsing fails
	}
	return parsed;
};

const works = safeJSONParse("{}"); // {}
const fails = safeJSONParse(".."); // undefined

But we can get rid of that as well, using Promises. When we throw inside a promise, it goes to the catch handler automatically, so we can replace the code above with:

const safeJSONParse = value =>
	new Promise(resolve => resolve(JSON.parse(value)))
		// If it fails, just return undefined
		.catch(() => undefined);

safeJSONParse("{}").then(works => ({
	/* {} */
}));

safeJSONParse("..").then(fails => ({
	/* undefined */
}));

Or we can just use async/await and then:

const works = await safeJSONParse("{}"); // {}
const fails = await safeJSONParse(".."); // undefined

Loops (for/while)

The for and while statements are used to loop over a "list" of things, but nowadays, we have way better ways of doing that with the methods that come with some of those lists (arrays) or other functions that help us keep the same type of looping for objects as well. So let's start with the easiest, which is arrays:

const users = [
	{ name: "Luke", age: 32 },
	{ name: "Gandalf", age: 24_000 },
];

// Just logging
for (const { name, age } of users) {
	console.log(`The age of ${name} is ${age}`);
}

// Calculating average
let ageTotal = 0;
for (const { age } of users) {
	ageTotal += age;
}
console.log(`The average age is ${ageTotal / users.length}`);

// Generating new array from previous
const usersNextYear = [];
for (const { name, age } of users) {
	usersNextYear.push({ name, age: age + 1 });
}

Instead of using for for this, we can just use the Array.prototype.forEach for the logs, Array.prototype.reduce for the average, and Array.prototype.map for creating a new array from the previous one:

// Just logging
users.forEach(({ name, age }) => console.log(`The age of ${name} is ${age}`));

// Calculating average
console.log(
	`The average age is ${users.reduce(
		(total, { age }, index, items) =>
			(total + age) / (index === items.length - 1 ? items.length : 1),
		0,
	)}`,
);

// Generating new array from previous
const usersNextYear = users.map(({ name, age }) => ({ name, age: age + 1 }));

There is an array method for everything we want to do with an array. Now, the "problems" start when we want to loop over objects:

const ages = {
	Luke: 32,
	Gandalf: 24_000,
};

// Just logging
for (const name in ages) {
	console.log(`The age of ${name} is ${ages[name]}`);
}

// Calculating average
let ageTotal = 0;
let ageCount = 0;
for (const name in ages) {
	ageTotal += ages[name];
	ageCount += 1;
}
console.log(`The average age is ${ageTotal / ageCount}`);

// Generating new object from previous
const agesNextYear = {};
for (const name in ages) {
	agesNextYear[name] = ages[name] + 1;
}

I put the word "problem" between quotes because it was a problem before, but now we have great functions in Object: Object.entries and Object.fromEntries. Object.entries turn an object into an array of tuples, with the format [key, value], and Object.fromEntries takes an array of tuples with that format and returns a new object. So we can use all the same methods we would use with arrays, but with objects, and then get an object back:

// Just logging
Object.entries(ages).forEach(([name, age]) =>
	console.log(`The age of ${name} is ${age}`),
);

// Calculating average
console.log(
	`The average age is ${Object.entries(ages).reduce(
		(total, [, age], index, entries) =>
			(total + age) / (index === entries.length - 1 ? entries.length : 1),
		0,
	)}`,
);

// Generating new object from previous
const agesNextYear = Object.fromEntries(
	Object.entries(ages).map(([name, age]) => [name, age + 1]),
);

The most common argument about this approaches for loops is not against Array.prototype.map or Array.prototype.forEach (because we all agree those are better), but mainly against Array.prototype.reduce. I made a post on the topic in the past, but the short version would be: We should use whatever makes the code more readable for our teammates. If the reduce approach ends up being too lengthy, we can also make a similar approach to the one with for, but using Array.prototype.forEach instead:

let ageTotal = 0;
users.forEach(({ age }) => (ageTotal += age));
console.log(`The average age is ${ageTotal / users.length}`);

The idea with the approach using array methods is also to move logic to functions, so let's take the last example of looping over objects and make it cleaner:

// If we will do several operations over an object, ideally we save the entries
// in a constant first...
const agesEntries = Object.entries(ages);

// We extract logic away into functions...
const logNameAndAge = ([name, age]) =>
	console.log(`The age of ${name} is ${age}`);

const valueAverage = (total, [, value], index, entries) =>
	(total + value) / (index === entries.length - 1 ? entries.length : 1);

const valuePlus1 = ([key, value]) => [key, value + 1];

// Now this line is readable...
agesEntries.forEach(logNameAndAge);

// Calculating average
console.log(`The average age is ${agesEntries.reduce(valueAverage, 0)}`);

// Generating new object from previous
const agesNextYear = Object.fromEntries(agesEntries.map(valuePlus1));

And not only more readable but also now we have generic functionality that we can reuse, such as the valueAverage or valuePlus1.

The other thing I forgot that usually replaces for and while is recursion (a function that calls itself), but I don't usually use recursion myself. So, let's only do the sum of an array of numbers:

const sum = array => (array.length > 0 ? sum(array.slice(1)) + array[0] : 0);

sum takes an array and calls itself until no array is left, adding the values in it and returning the total.

Common arguments against removing control statements

"Ternaries are hard to teach to juniors"

If a junior understands an if, they also understand a ternary. It is easy to explain: It can be read as a question with the answer for when it is true and another answer for when it isn't.

"Minifiers already do some of this work for us"

The argument here is not to make code shorter for the computer. Instead, the idea is to use better solutions for logic structures with a more functional approach. For example, the fact that ternaries force the developer from the syntax to cover all logic branches.

"for is more readable than array methods"

I disagree, but I guess it depends on the developer. For me, at least, this:

array.forEach(log);

Is more readable than this:

for (let i; i < array.length; i++) {
	log(array[i]);
}

Or even this:

for (const item of array) {
	log(item);
}

Closing thoughts

As usual, I invite readers of my series' articles just to try these approaches. Unfortunately, we are used to defaulting to some of these control statements instead of thinking of better ways that might be a better fit for a particular problem. More often than not, we can replace them with a more functional approach.

Share this article