01. Nested Ternaries
How many times does “else if” appear in your codebase? Let’s examine one way you can cut down on if-else statements in today’s episode of TL;DR, the JavaScript codecast series that teaches working web developers to craft exceptional software in 5 minutes a week.
Transcript
The if-else statement is probably the first control flow construct you learned as a programmer. And yet few things are as terrifying as diving into a legacy codebase swimming in nested, cascading if-else statements.
Code with fewer if-else statements is generally less complex because it has fewer edge cases that need to be tested, and code with fewer statements tends to have a more predictable program flow. A program without any if-else statements or other conditional constructs is incredibly straightforward to reason about, because data will always flow through the program in the same way, even when the inputs and output change.
Now it’s unlikely you could eliminate all if-else statements from a program without making readability worse off. But a lot of if-else statements is a code smell, because they unnecessarily increase the complexity and surface area for bugs.
So in the next few episodes of TL;DR we’ll cover some design patterns to cut down on if-else statements.
Today we’re examining some recursive code with several cascading if-else statements.
resolve({
user: {
firstName: 'Jonathan',
lastName: 'Martin',
favoritePlaces: () => [
'Alps',
'PNW'
]
}
});
/*
* Spits out:
*/
{
user: {
firstName: 'Jonathan',
lastName: 'Martin',
favoritePlaces: [
'Alps',
'PNW'
]
}
};
This resolve function walks through an object with nested objects, arrays and functions. Given a deeply nested structure like this, it returns a similar structure, but where the functions — like this one for the property favoritePlaces, which was originally a function — have been invoked and replaced with their return value.
Now the logic for the resolve function is pretty terse: if the current structure is an array or object, it recurses over the children. If it’s a function, it invokes it and recurses over the return value. Otherwise, it will just return the structure as-is.
let resolve = (node) => {
if (isFunction(node)) {
return resolve(node());
} else if (isArray(node)) {
return node.map(resolve);
} else if (isObject(node)) {
return mapValues(node, resolve);
}
return node;
};
Now these if-else statements aren’t complex per se, in fact it almost looks like it could be a switch statement instead. The problem is the testing conditions — that is, whether the data is a function, array or object — can’t be described with strict equality, which we would need to use a switch statement. Hence, we had to stick with if-else statements instead.
So if the test condition is too complex for a switch statement, is there an alternative that might at least move us away from if-else statements?
Well, the ternary operator is essentially an if-else expression. While an if-else statement runs statements but doesn’t return anything, a ternary expression evaluates and returns the value of one of the two expressions. Let’s write a new version of the resolve function and convert the first if-else case to a ternary: if the node is a function, the ternary evaluates to this case on the left, but otherwise it will evaluate to the case on the right, that is, the node. Like an if-else statement, only code in the matching case is evaluated — the other is completely ignored.
Because JavaScript developers don’t often see ternaries used in production codebases, there is a stigma that ternaries are brittle and have finicky syntax rules. But ternaries are actually more robust than an equivalent if-else statement because you can only embed expressions, and not statements. That makes it harder to sneak a side effect in, like setting a variable or forcing an early return.
The main frustration for many developers is reading another developer’s one-liner ternary, so it’s essential to space them out just like you would an if-else statement.
let resolve = (node) => {
return isFunction(node)
? resolve(node())
: node;
};
So instead of putting all this on one line, you should indent each case like this. You’ll find this convention popular in the React community for switching between components. With a little practice, a ternary becomes easier to read than the equivalent if-else statement.
But what about those cascading else-ifs we had before? Well since ternaries are just expressions, we can nest else-ifs in the last part of the ternary!
let resolve = (node) => {
return isFunction(node)
? resolve(node())
: (isArray(node) ? node.map(resolve) : node);
};
Well this is pretty awful to read, let’s fix that with some indentation. Ternaries are designed to cascade, so the parentheses are actually unnecessary. Next, let’s insert a new line after the question marks instead of before. Then unindent each line that starts with a colon so it lines up with the first line.
And for the final else case, the colon will be on a line by itself.
let resolve = (node) => {
return isFunction(node) ?
resolve(node())
: isArray(node) ?
node.map(resolve)
:
node;
};
Let’s practice reading this: if the node is a function, it returns the result of this line, otherwise if node is an array, it returns the result of this line, and finally if the node is neither a function nor an array, node is returned.
Wait a minute, we forgot to add a case for when the node is an object! Well to add it, we can just insert it before the final else.
let resolve = (node) => {
return isFunction(node) ?
resolve(node())
: isArray(node) ?
node.map(resolve)
: isObject(node) ?
mapValues(node, resolve)
:
node;
};
By formatting our ternaries like this, we can easily add and rearrange cases without changing other lines or fretting about nested parentheses!
And now that resolve is only one statement, we can drop the curlies and return keyword to make resolve an implicit returning arrow function. In this style, I like to unindent the testing conditions one more level. Now all of the test cases line up in one column, and all of the possible return values line up in another.
let resolve = (node) =>
isFunction(node) ?
resolve(node())
: isArray(node) ?
node.map(resolve)
: isObject(node) ?
mapValues(node, resolve)
:
node;
From a control flow perspective, we’ve achieved the holy grail: the resolve function has no variables, no early returns and no statements.
Now you might feel that this exercise of switching from if-else statements to ternary expressions was purely aesthetic, but syntax is just a nice side benefit of the real benefits:
Whereas if-else statements are popular in imperative programming, which is built on control flow, ternary expressions help us think about data flow and produce more declarative code. Functions with a lot of statements tend to have several entry and exit points that new team members need to parse through to keep from introducing a bug. But functions composed of expressions tend to flow in the same way for any inputs.
Today, look through your codebase for cascading if-else statements where each case is roughly the same, like returning a value or setting a variable, and try swapping the if-else for nested ternaries. And in the future, I would encourage you to default to nested ternaries, and make if-else statements the exception. You’ll find they force you to design your code better to begin with.
That’s it for today. Want to keep leveling up your craft? Don’t forget to subscribe to the channel for more rapid codecasts on design patterns, refactoring and development approaches.