We interrupt this doomscroll
to bring you...

Crash Lime

Delicious performance
64 bites at a time!

2023-04-14 • 5 minutes required • Anima Omnium

Optional If Expressions

A while back, Robert Nystrom published a post on type-checking if-expressions. If-expressions are generally a feature of expression-oriented languages, in which all language constructs produce a value. An if-expression takes on the value produced by the selected branch:

// Using Rust for this post
let title = if favorite {
  "Best Muffin Recipe"
} else {
  "Decent Muffin Recipe"

Because if-expressions must produce a value, an else branch is needed; because either branch could be taken, both branches must produce a value of the same type.

In statement-oriented languages, however, it is common to write if-statements without a trailing else, because they perform a side effect rather than producing a useful value:

let mersenne = 2.pow_i(n) - 1;
if is_prime(mersenne) {
  println!("Found prime: {}", mersenne);

This is a super-common pattern in real-world code. Quoting Nystrom:

[…] in imperative code, it’s obviously common to have ifs whose main purpose is a side effect and where an else clause isn’t needed. In fact, when I analyzed a huge corpus of real-world Dart, I found that only about 20% of if statements had else branches.

It is possible, however, for an expression-oriented language to have if-expressions without else branches. If the if doesn’t produce a ‘value’ (e.g. produces Unit or Absurd) or the value produced is never consumed, then an else branch is obviously not required.

Nystrom goes on to explain his approach for keeping track of whether the context will consume the value or not, so that it is possible to write single-branch if-expressions in non-expression contexts. (Nystrom goes a step further by relaxing the ‘same-type’ constraint in contexts where the value is not used.)

While keeping track of contexts works well in interpreted languages, there’s a simple way to adapt if-expressions so that they work in expression context:

What if single-branch if-expressions just produced an optionally-typed value? To illustrate:

let quotient: Option<f64>;
quotient = if den != 0.0 {
  num / den

The above example would be equivalent to:

// …
quotient = if den != 0.0 {
  Some(num / den)
} else {

This pattern could be useful for look-before-you-leap type contexts, where a potentially fallible expression needs to check some preconditions before running.

Currently, this feature is available as a method on bool in Rust:

// …
quotient = (den != 0.0)
  .then(|| num / den);

While this works, and composes well when chaining methods, on its own using then in this manner is not that readable. (It suffices to say that I’m not a huge fan of passing closure callbacks a methods.)

Optional if-expressions neatly generalize the semantics of if-expressions to single-branch contexts. As a plus, the language is no longer required to differentiate between if-statements and if-expressions, because both one-armed and two-armed variants now produce a useful value.

Unlike Nystrom’s solution, optional if-expressions do not require keeping track of expression vs. non-expression contexts. Does this mean they’re easier to implement? Not exactly.

Optional if-expressions only make sense in the context of a language with a generic Option<T> type, a feature that is complex, which feature Nystrom’s language does likely not have. Admittedly, this language feature makes more sense in a language with generic ADTs (like Rust) than the language Nystrom is writing.

The question, however, of how to integrate imperative, statement-oriented if-statements in an expression-oriented language, is an interesting one. Optional if-expressions are an unambiguous syntactic transformation, sugar for a common pattern often used. While they neatly resolve a couple problems, they’re not perfect:

I’m not a huge fan of the implicit introduction of the Option type. In a language like Rust, implicitly introducing Option is barely acceptable: Option is already a priveleged type, and ‘accidentally’ assigning such an optional if-expression to a variable will likely cause a type mismatch error, caught by the compiler.

In a dynamically-typed language with nullable values, the idea of nullable if-expressions is a terrible one. Normally, we have to check for null values before performing an action. A nullable if-expression performs this check, but discards the result. The difference between optional and nullable if-expressions is the same as the difference between parsing and validating. Optional if-expressions reify the check, producing a wrapped value; nullable if-expressions do no such thing.

Taking a step back, in all honesty, I’m not a huge fan of if-expressions and, well, booleans in general. In the long run, I think that pattern-matching on structured data is a much cleaner and less error-prone route. The issue of deeply-nested pattern-matching can be resolved with a little sugar (e.g. do/with/use notation in Haskell/Koka/Gleam): there’s no reason not to match!

But until then, let’s at least make the if-expressions we have now a little nicer!