6

Rethinking the JavaScript ternary operator

 3 years ago
source link: https://jrsinclair.com/articles/2021/rethinking-the-javascript-ternary-operator/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Rethinking the JavaScript ternary operator

Written by James Sinclair on the 15th March 2021


We all want to write code that’s both clear and concise. But sometimes we have to choose between the two. We can be either clear or concise, but not both at the same time. And it can be hard to choose a path. There are good arguments for both sides. Fewer lines of code mean fewer places for bugs to hide. But clear, readable code is easier to maintain and modify. In general, though, conventional wisdom tells us that clarity trumps concision. If you have to decide between readability and brevity, choose readability.

It makes sense then, that many people treat the ternary operator with suspicion. Sure, it’s more concise than an if-statement. But it’s all too easy to turn ternaries into an indecipherable mess. Thus the handle with care advice makes sense. Prefer if-statements in general. Err on the side of readability.

But what if we’re missing something in this exchange? What if there’s a proverbial baby we’re throwing out with the metaphorical bathwater? Ternaries and if-statements are related, yes, but not equivalent. They have important differences people frequently overlook. And those differences have consequences for your code.

The trouble with ternaries

Why do people treat the ternary operator with such suspicion? Is it that bad? It’s not like the average coder wakes up one morning and thinks to herself, “I’m going to hate on ternaries today.” The suspicion has to come from somewhere. People have good reasons for disliking ternary operators. Let’s look closer at a few of them.

Weird

One reason people dislike ternaries is that they’re just plain weird. As operators, that is. JavaScript has lots of binary operators—operators that act on two expressions. You’re probably familiar with arithmetic operators like +, -, * and /. And with boolean operators like &&, || and ===. There are at least 28 binary operators in total. (That is, depending on which ECMAScript version we’re talking about). They’re familiar and intuitive. An expression on the left, the operator symbol, and an expression on the right. Simple.

There are fewer unary operators. But they’re not so weird either. You’re probably familiar with the negation operator, !. And you’ve perhaps also used + and - in their unary form too. For example, -1. Most of the time, they operate on an expression to the right of the symbol. And they don’t cause much trouble.

There’s only one ternary operator though. And, as the name implies, it operates on three expressions. Hence we write it using two symbols: ? and :. Otherwise, we can’t tell where the middle expression starts and finishes. So it looks something like this:

(/* First expression*/) ? (/* Second expression */) : (/* Third expression */)

And in practice, we use it like so:

const protocol = (request.secure) ? 'http' : 'https';

If the first expression is ‘truthy,’ the ternary resolves to the value of the second expression. Otherwise, it resolves to the value of the third expression. But to keep those three expressions distinct, we need two symbols. No other operator is made up of multiple symbols like this.

That’s not the only weird thing about it though. Most binary operators have a consistent type. Arithmetic operators work on numbers. Boolean operators work on booleans. Bitwise operators, again, work on numbers. For all of these, the type is the same on both sides.1 But the ternary operator has weird types. With the ternary operator, the second and third expressions can be any type. But the interpreter will always cast the first to a boolean. It’s unique. So, as far as operators go, it’s odd.

Unhelpful for beginners

So the ternary operator is weird. It’s not surprising then that people criticise it for confusing beginners. There’s a lot to remember there. If you see a question mark symbol, you have to go looking for a colon. And unlike an if-statement, it’s hard to read a ternary as pseudo-English. For example, imagine we had an if statement like so:

if (someCondition) {
    takeAction();
} else {
    someOtherAction();
}

It doesn’t take a lot of effort to translate that into prose. If someCondition evaluates as true then call the function takeAction with no arguments. Else, call the function someOtherAction with no arguments. That’s not a big leap. The ternary operator though is made-up of cryptic symbols. It doesn’t read like English. It’s more effort. And learning to code is difficult enough as it is.

Difficult to read

Even if you’re not a beginner, ternaries can be difficult to read. Those cryptic symbols can trip up the best of us. Especially if the ternary brackets long expressions. Consider this example using the Ratio library:2

const ten = Ratio.fromPair(10, 1);
const maxYVal = Ratio.fromNumber(Math.max(...yValues));
const minYVal = Ratio.fromNumber(Math.min(...yValues));
const yAxisRange = (!maxYVal.minus(minYVal).isZero()) ? ten.pow(maxYVal.minus(minYVal).floorLog10()) : ten.pow(maxYVal.plus(maxYVal.isZero() ? Ratio.one : maxYVal).floorLog10());

It’s difficult to tell what’s going on there. Each expression in the ternary has at least two chained method calls. Not to mention another ternary nested inside the final expression. This ternary expression is hard to read. I do not recommend you write code like this.

We could, of course, make it slightly better by adding line breaks. Prettier (the formatting library) would do it like so:

const ten = Ratio.fromPair(10, 1);
const maxYVal = Ratio.fromNumber(Math.max(...yValues));
const minYVal = Ratio.fromNumber(Math.min(...yValues));
const yAxisRange = !maxYVal.minus(minYVal).isZero()
    ? ten.pow(maxYVal.minus(minYVal).floorLog10())
    : ten.pow(maxYVal.plus(maxYVal.isZero() ? Ratio.one : maxYVal).floorLog10());

This is marginally better. But not a great improvement. We can make another minor improvement by adding vertical alignment.

const ten        = Ratio.fromPair(10, 1);
const maxYVal    = Ratio.fromNumber(Math.max(...yValues));
const minYVal    = Ratio.fromNumber(Math.min(...yValues));
const yAxisRange = !maxYVal.minus(minYVal).isZero()
                 ? ten.pow(maxYVal.minus(minYVal).floorLog10())
                 : ten.pow(maxYVal.plus(maxYVal.isZero() ? Ratio.one : maxYVal).floorLog10());

It’s still difficult to read though. In general, it’s far too easy to put too much into a ternary. And the more you put into them, the more difficult they are to read.

Nesting ternaries is particularly problematic. It’s far too easy to miss a colon as you’re reading. In the example above, the line breaks help a bit. But it would be all to easy to do something like the following:

const ten        = Ratio.fromPair(10, 1);
const maxYVal    = Ratio.fromNumber(Math.max(...yValues));
const minYVal    = Ratio.fromNumber(Math.min(...yValues));
const yAxisRange = !maxYVal.minus(minYVal).isZero()
                 ? ten.pow(maxYVal.minus(minYVal).floorLog10()) : ten.pow(maxYVal.plus(maxYVal.isZero() ? Ratio.one
                 : maxYVal).floorLog10());

Of course, this is a made up example. So it’s something of a straw-man argument. I’ve deliberately written bad code to illustrate the issue. But the point remains. It’s all too easy to write unreadable ternary expressions. Especially with nested ternaries. And readability matters. As Martin Fowler said:

Any fool can write code that a computer can understand. Good programmers write code that humans can understand.3

We write code to be read. And this is the main problem people have with ternaries. It’s far too easy to shove too much into them. And as soon as you start nesting them, your chances of creating a mess increase exponentially. So I can understand why you might encourage junior programmers to avoid ternaries. Much better to stick with nice, safe if-statements.

But how safe are if-statements?

The untrustworthiness of if-statements

Ternaries have their drawbacks. And if that’s all there was to it, I’d eschew ternaries without question. I want my code to be easy for others to read—including beginners. But the ternary naysayers tend to make two assumptions:

  1. The only reason to use a ternary is to be concise or clever; and
  2. An if-statement would serve just as well in the ternary’s place.

The more I consider it, the more I’m convinced neither assumption is true. There are good reasons to use a ternary. Reasons that have nothing to do with writing shorter code. This is because if-statements and ternary operators are different. Not subtly different—significantly different. Different in a way that gets right down to the building blocks of JavaScript.

To illustrate, let’s look at an example. Here are two pieces of code.

First, an if-statement:

let result;
if (someCondition) {
    result = calculationA();
} else {
    result = calculationB();
}

And next, with the ternary:

const result = (someCondition) ? calculationA() : calculationB();

People tend to assume these two examples are equivalent. And in one sense, they’re right. At the end of both pieces of code, a variable called result will be set to a value. Either the result of calculationA() or calculationB(). But in another sense, these two examples are quite different. And that let in the if-statement example gives us our first clue.

What’s the difference? In short, an if-statement is, well, a statement, while a ternary is an expression.

But what does that mean? Here’s a summary:

  • An expression always evaluates to some value.
  • A statement is a “standalone unit of execution” 4

This is an important concept. An expression evaluates to a value. A statement does not. You can’t assign the result of a statement to a variable. You can’t pass the result of a statement as a function argument. And an if-statement is a statement, not an expression. An if-statement doesn’t resolve to a value. So the only way it can do anything useful at all is by causing side-effects.

What’s a side effect? A side effect is anything our code does besides resolving to a value. This includes a whole lot of things:

  • network calls;
  • reading and writing files;
  • database queries;
  • modifying DOM elements;
  • mutating global variables;
  • even writing to the console.

They’re all side effects.

Now, someone might be thinking “So what? Who cares if we’re causing side effects?” After all, side effects are the whole reason we code, right? As long as we’re getting the job done, what does it matter?

In one sense, it doesn’t matter. Working code is what matters. On that, we agree. But how do you know it’s working? And how do you know that your program only does what you think it does. How do you know it’s not also mining Dogecoin or dropping database tables?

In a way, this is the core idea of functional programming. We gain confidence in our code by treating side effects with great care. Wherever possible, we prefer to work using pure functions. If a function is pure, we know that it does nothing but make a calculation and return a value. That’s it.

What does this mean for if-statements and ternaries? It means that we should treat if-statements with a certain amount of suspicion. Let’s take a look at our example from earlier.

if (someCondition) {
    takeAction();
} else {
    someOtherAction();
}

It doesn’t matter which branch someCondition leads us down. The only thing the if-statement can do is cause a side effect. It calls either takeAction() or someOtherAction(). But neither of those returns a value. (Or, if they do, we’re not assigning it to anything.) The only way those functions can do anything useful is by reaching outside the block. It might be something benign, like mutating a value in the outer scope. But it’s a side effect nonetheless.

Am I suggesting we should never use if-statements? No. But do recognise them for what they are. Every time you see one, you must ask yourself “what side effect is happening here?”. If you can’t answer the question, you don’t understand the code.

Reconsidering ternaries

It seems we have good reason to be suspicious of if-statements. What about ternaries then? Are they always better? No, but yes … and no. All the criticisms we discussed earlier are still valid. But, ternaries at least have the advantage of being expressions. That means they’re less suspicious—at least when it comes to side effects. But side effects aren’t the only reason we prefer coding with expressions.

We like expressions because expressions compose better than statements. Operators and functions allow us to build complex expressions out of simple ones. For example, we can build complex strings with the concatenation operator:

'<h1>' + page.title + '</h1>’;

We could take this expression and pass it as a function argument. Or we could combine it with other expressions using more operators. We can keep combining expressions with expressions to perform complex calculations. Composing expressions is an excellent way to write code.

Except, you might be wondering: “Why is that so special? Aren’t statements ‘composable’ too? We can happily stick a for-loop inside an if-statement. And a case-switch statement inside a for-loop, no trouble. Statements nest inside each other just fine. We can use statements to build other, more complex statements. What’s the big deal with expressions?”

The advantage expressions have over statements is something we call referential transparency. This means we can take the value of an expression and use it anywhere we would have used the expression itself. And we can do this with mathematical certainty that the result will be the same. Exactly. Always. 100%. Every time.

Now, you might be thinking, “What’s that got to do with composition?” Well, referential transparency explains why composing statements is different from composing expressions. And the best analogy I can think of is LEGO® bricks versus calico grocery bags.

Statements compose the way that calico grocery bags compose. I can put calico bags inside calico bags just fine. And those bags might have other things in them. I can even carefully wrap individual objects in calico bags. And then place those wrapped objects in neat stacks inside other calico bags. The result might even be aesthetically pleasing. But the bags don’t have any real relationship to each other. They’re connected by being nested. But that’s it. There’s no organising principle for connecting bags.

Similarly, some statements can nest. That is, the ones with blocks can (e.g. if-statement and for-loops). But they don’t have any connection with each other. The blocks are just containers for whatever you want to stick in there. Which is fine, as far as it goes. But it’s a different kind of composition from expressions.

Expressions are more like LEGO® bricks. They’re limited in how they compose. Nubs on the top connect with gaps on the bottom of the brick. But once joined, the bricks form a new shape. And that shape can be interchanged with any other shape with the same configuration. Consider the picture below. We have two connected shapes. And though the shapes are composed of different blocks, the resulting shapes are the same. To put it another way, they’re interchangeable. Similarly, an expression is interchangeable with its calculated value. It doesn’t matter how we calculate the value. It’s the result that matters.

To shapes constructed of LEGO bricks. The one on the left is constructed from three pieces: Two green 2 by 2 bricks stacked on top of one blue 2 by 4 brick. The one on the right is constructed from two blue 2-by-4 bricks stacked together.
Two shapes constructed from LEGO® bricks. Even though they’re composed of different bricks, the shapes are the same.

Now, the analogy isn’t perfect. It fails because calico bags serve a different purpose to LEGO® bricks. But it’s just an analogy. The idea remains. Composing expressions has distinct advantages. Advantages we don’t get when composing statements. And since the ternary operator is an expression, it has advantages over if-statements.

Does that mean we should always prefer ternaries though? Are they definitively better? The unfortunate answer is, no. In JavaScript, like most languages, you’re free to cause side-effects wherever you like. That includes inside expressions. And the price of that freedom is eternal vigilance. You never know where an unexpected side-effect might show up. For example:

const result = (someCondition) ? dropDBTables() : mineDogecoin();

We can’t dismiss ternaries out of hand though. Because if-statements are not the-same-thing-but-more-verbose. When you see a ternary, consider that the author might have made a deliberate choice. They might have good reasons for using ternaries besides brevity.

Responsible use of conditionals

So what do we do then? Ternaries aren’t that great. And if-statements aren’t that awesome either. What do we do? Use another language?

Perhaps. But often that’s not an option. So the most accurate, universally-applicable advice I can give is: Use your discretion. Consider your colleagues’ coding styles and preferences. Take into account the specifics of the problem you’re trying to solve. Weigh up the options and make a call.

Except, as advice, that’s not so helpful. You could say that about any coding problem. It doesn’t help us with conditionals. So in the interest of being helpful, I’ll give some specific advice. But with an enormous caveat. This is just my opinion. Other people have different opinions. And that’s OK. These aren’t commandments or laws. Just my preferences on how to write safer conditionals.

Some statements are better than others

Before we get to the specifics, let’s consider the structure of JavaScript code for a moment. You’ll notice that it’s impossible to write decent code without statements. JavaScript programs are mostly statements. You can’t escape them. But some statements are safer than others.

The most dangerous statements are those with blocks. (Those are the bits with curly braces {…}). This includes if-statements, for-loops, while-loops and switch-case-statements. They’re dangerous because the only way to do something useful with them is to cause some kind of side-effect. Something has to reach outside the block scope and change the environment.

The safer statements are variable assignments and return statements. Variable assignments are handy because they bind the result of an expression to a label. We call this a variable. And that variable is itself an expression. We can use it again, as often as we like, in other expressions. So, as long as we’re careful about avoiding mutation, variable assignments are pretty good.

Return statements are useful because they make function calls resolve to a value. And function calls are expressions. So, like variable assignments, return statements help us build expressions. So they’re also pretty good, most of the time.

With this knowledge, we can think about how to write safer conditionals.

Safer if-statements

To write safer if-statements I follow a simple rule: The first (‘then’) branch must end with a return. That way, even though the if-statement doesn’t resolve to a value, the outer function will. For example:

if (someCondition) {
    return resultOfMyCalculation();
}

If you follow this rule, as a consequence, you will never need an else-block. Not ever. Conversely, if you do introduce an else-block, you know you’ve introduced a side-effect. It might be small and harmless, but it’s still there.

More readable ternaries

My general advice with ternaries is to keep them small. If an expression gets too long, use vertical alignment to clarify intent. Or better still, add some variable assignments. For example, we could improve our example from earlier:

const ten     = Ratio.fromPair(10, 1);
const maxYVal = Ratio.fromNumber(Math.max(...yValues));
const minYVal = Ratio.fromNumber(Math.min(...yValues));

// Create four extra variables to label the bits that go in the
// ternary. It's now clearer what each calculation is for.
const rangeEmpty = maxYVal.minus(minYVal).isZero();
const roundRange = ten.pow(maxYVal.minus(minYVal).floorLog10());
const zeroRange  = maxYVal.isZero() ? Ratio.one : maxYVal;
const defaultRng = ten.pow(maxYVal.plus(zeroRange).floorLog10());

// Piece together the final ternary out of the variables.
const yAxisRange = !rangeEmpty ? roundRange : defaultRng;

Now, someone might point out that we’re doing unnecessary calculation now. We don’t need to calculate zeroRange or defaultRng if rangeEmpty is false. To avoid that, we can use functions.

const ten     = Ratio.fromPair(10, 1);
const maxYVal = Ratio.fromNumber(Math.max(...yValues));
const minYVal = Ratio.fromNumber(Math.min(...yValues));

// Create two functions so we only calculate the range we need.
const rangeEmpty = maxYVal.minus(minYVal).isZero();
const roundRange = () => ten.pow(maxYVal.minus(minYVal).floorLog10());
const defaultRng = () => {
    const zeroRange  = maxYVal.isZero() ? Ratio.one : maxYVal;
    return ten.pow(maxYVal.plus(zeroRange).floorLog10());
};

// Piece together the final ternary using our two new functions.
const yAxisRange = !rangeEmpty ? roundRange() : defaultRng();

Now, the whole thing is a lot longer than before. But that’s not necessarily a bad thing. We prefer clarity over brevity, right? In this version, the intention of the code is clearer.

But what about nesting ternaries? Isn’t that always bad? Well , no. If you’re careful about vertical alignment, even deep-nested ternaries can be readable. In fact, I often prefer them to case-switch statements. Particularly when I have something like a lookup-table. In those cases, ternaries allow me to format things like a table. For example:

const xRangeInSecs = (Math.max(...xValues) - Math.min(...xValues));
// prettier-ignore
const xAxisScaleFactor =
    (xRangeInSecs <= 60)       ? 'seconds' :
    (xRangeInSecs <= 3600)     ? 'minutes' :
    (xRangeInSecs <= 86400)    ? 'hours'   :
    (xRangeInSecs <= 2592000)  ? 'days'    :
    (xRangeInSecs <= 31536000) ? 'months'  :
    /* otherwise */              'years';

If you’re using a formatter like Prettier, you’ll need to disable it. You can use an inline comment as I’ve done above.

It does take some work, but it is possible to use ternaries and if-statements responsibly. And yes, it comes with a price. Not only does it take effort, but we may have to defy linters and coding standards as well. And people will shove too much into ternaries. Either due to laziness or because they simply didn’t know any better. But I think that’s better than blindly assuming if-statements are ‘safe’.

The future

Even though we can write responsible conditionals, our options are limited. But there there is some hope for change. Check out the TC39 “do expressions” proposal. The addition to the language would allow us to turn many statements into expressions. For example, we’d be able to write code like this:

let x = do {
  if (foo()) { f() }
  else if (bar()) { g() }
  else { h() }
};

The do block can contain any number of statements, and resolves to the ‘completion value’. That is, the last value evaluated before finishing the do block.

Several people have pointed out that this would be handy for JSX. Inside a JSX component, you’re normally limited to expressions only. With a do expression, you could sneak in some statements, which might make for more readable code.

The proposal was presented to the TC39 meeting in June 2020, but it has not yet moved past Stage 1. (At least, not at the time of writing). So it may be a while before it lands in browsers and Node. In the meantime, if you’re keen, there’s a Babel transform for it.

One final thought… perhaps it would be a good idea to reconsider the comma operator too. But that would be a whole other post.

Conclusion

In general, most of us agree that writing clear code is more important than being concise. So it’s understandable that people give ternaries a good, hard squint. But perhaps consider that being clever or overly-concise isn’t the only reason to use a ternary. And I also encourage you to take a good hard look at your if-statements. Just because something is familiar, doesn’t mean it’s safe.

Addendum (2021–03–16): If you’re interested in tuning ESLint to specify your ternary preferences, Kyle Simpson has created a nifty ESlint plugin. Personally, I wouldn’t leave it set to the defaults. But it gives a lot more control than the built-in ESLint rules.


  1. To be fair, in JavaScript, boolean operators don’t cast the second expression. This allows us, for example, to abuse the || operator to set default values. e.g.: const sortOrder = sortParam || 'asc';  ↩︎

  2. This is an intentionally bad example, but it’s based on code from a real project I'm working on.  ↩︎

  3. Martin Fowler, 2008, Martin Fowler, https://en.wikiquote.org/wiki/Martin_Fowler  ↩︎

  4. Scott Wlaschin, 2012, Expressions vs. statements.  ↩︎


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK