We don't need control statements
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
orfor
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 if
s:
- They force us to cover all the logic branches. This means “every
if
has a mandatoryelse
.” - They reduce the amount of code drastically (we just need a
?
and a:
). - 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 if
s (if
s inside an if
s), 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: "Lou", age: 32 }, { name: "Gandalf", age: 24_000 },];
// Just loggingfor (const { name, age } of users) { console.log(`The age of ${name} is ${age}`);}
// Calculating averagelet ageTotal = 0;for (const { age } of users) { ageTotal += age;}console.log(`The average age is ${ageTotal / users.length}`);
// Generating new array from previousconst 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 loggingusers.forEach(({ name, age }) => console.log(`The age of ${name} is ${age}`));
// Calculating averageconsole.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 previousconst 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 = { Lou: 32, Gandalf: 24_000,};
// Just loggingfor (const name in ages) { console.log(`The age of ${name} is ${ages[name]}`);}
// Calculating averagelet 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 previousconst 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 loggingObject.entries(ages).forEach(([name, age]) => console.log(`The age of ${name} is ${age}`),);
// Calculating averageconsole.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 previousconst 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 averageconsole.log(`The average age is ${agesEntries.reduce(valueAverage, 0)}`);
// Generating new object from previousconst 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.