Ternaries could be beautiful

Stumbled on this tweet the other day:

@trashh_dev tweet about ternary formatting

Look, I don't think ternaries are evil. Sometimes you just want conditional expressions, so you can't use early returns nor switch(true), like when your writing conditional types in TypeScript. Plus you don't want to go through the hassle of IIFEs either. In other languages, you might reach for pattern matching, but JavaScript doesn't have that (yet). And in those cases, ternaries might be a good choice.

But we all agree that formatting them like this is just plain ugly, right?

const foo = () =>
  isSomething
    ? "yesyesyes"
    : isSomethingElse
      ? "maybemaybemaybe"
      : isMaybeThatThough
        ? "thatOtherThing"
        : "nonono"

It always take me a hot second to figure out what's going on when ternaries are formatted this way. Plus I'm starting to get neck pain from all the nested indentation.

Luckily, Prettier is looking into how to better format ternaries:

// prettier-ignore
const animalName =
  pet.isScary() ? 'wolf'
  : pet.canBark() ? 'dog'
  : pet.canMeow() ? 'cat'
  : 'probably a bunny'

Prettier calls this "case-style" ternaries. And honestly, it's pretty good.

But you already know a better way to format ternaries. Don't believe me?

Formatting early returns

Ok, let's rewrite it with if statements but keep the nesting:

const foo = () => {
  if (isSomething) {
    return "yesyesyes"
  } else {
    if (isSomethingElse) {
      return "maybemaybemaybe"
    } else {
      if (isMaybeThatThough) {
        return "thatOtherThing"
      } else {
        return "nonono"
      }
    }
  }
}

Eww. Sorry, I had to show you that.

Anyway, you already know how these if statements should be formatted:

const foo = () => {
  if (isSomething) return "yesyesyes"
  if (isSomethingElse) return "maybemaybemaybe"
  if (isMaybeThatThough) return "thatOtherThing"
  return "nonono"
}

Ah, that's better. But why is it better? And what does it teach us about formatting ternaries?

Oh, and don't fret the lack of {}. You can write each if statement like this if you prefer:

if (isSomething) {
  return "yesyesyes"
}

It won't change any of the points we'll cover.

Form from function

Here's the mental model I have for the semantics of our little example:

foo:
  isSomething -> "yesyesyes"
  isSomethingElse -> "maybemaybemaybe"
  isMaybeThatThough -> "thatOtherThing"
  -> "nonono"

Look familiar?

Fade between mental model and code

The nested formatting was giving a false affordance of complexity. But with early returns, each case is on its own line so the syntax mirrors the semantics. Form from function; function from form.

And it's not just nicer in theory, it has HUGE practical benefits.

Fade between of code for adding, moving, and removing cases

As a bonus, you'll get way less git noise when making any of these changes than you would with the nested formatting.

Ok, now try doing any of those with the nested formatting. Yuck.

Another benefit is that it keeps the "happy path" or "interesting part" of the code unindented:

const fibonacci = (n: number) => {
  // base cases
  if (n === 0) return 0
  if (n === 1) return 1

  // interesting part
  return fibonacci(n - 1) + fibonacci(n - 2)
}

const login = (username?: string, password?: string) => {
  // preconditions
  if (!username) throw Error("Username required")
  if (!password) throw Error("Password required")
  if (!db.check(username, password)) throw Error("Invalid credentials")

  // interesting part
  let user = db.getUser(username)
  // ...bunch of other business logic...
  return redirect("/home")
}

So how'd we got from these semantics:

foo:
  isSomething -> "yesyesyes"
  isSomethingElse -> "maybemaybemaybe"
  isMaybeThatThough -> "thatOtherThing"
  -> "nonono"

to early return syntax? You already know the answer:

Replace <condition> -> <result> with if (<condition>) return <result>

Let's tweak that a little bit for ternaries;

Replace <condition> -> <result> with <condition> ? <result> :

// prettier-ignore
const foo = () =>
  isSomething ? "yesyesyes" :
  isSomethingElse ? "maybemaybemaybe" :
  isMaybeThatThough ? "thatOtherThing" :
  "nonono"

Daaaayyyuumm ternaries, is that you? 🤩 You're lookin' good!

Fade between adding, moving, and deleting cases for ternaries

And I'm not the only one that thinks so:

@mjackson confessing I've converted him to this format

Ok ok, that's all well and good for our simple example. But how well does this technique scale in real code?

Let's get real

Here's an example from arktype that's a little more complex:

// prettier-ignore
export const parseOperator = (s: DynamicState): void => {
  const lookahead = s.scanner.shift()
  return lookahead === ""
    ? s.finalize()
    : lookahead === "["
    ? s.scanner.shift() === "]"
        ? s.rootToArray()
        : s.error(incompleteArrayTokenMessage)
    : isKeyOf(lookahead, Scanner.branchTokens)
    ? s.pushRootToBranch(lookahead)
    : lookahead === ")"
    ? s.finalizeGroup()
    : isKeyOf(lookahead, Scanner.comparatorStartChars)
    ? parseBound(s, lookahead)
    : lookahead === "%"
    ? parseDivisor(s)
    : lookahead === " "
    ? parseOperator(s)
    : throwInternalError(writeUnexpectedCharacterMessage(lookahead))
}

Let's parse that into its semantics:

lookahead === "" -> s.finalize()
lookahead === "[" ->
    s.scanner.shift() === "]" -> s.rootToArray()
    -> s.error(incompleteArrayTokenMessage)
isKeyOf(lookahead, Scanner.branchTokens) -> s.pushRootToBranch(lookahead)
lookahead === ")" -> s.finalizeGroup()
isKeyOf(lookahead, Scanner.comparatorStartChars) -> parseBound(s, lookahead)
lookahead === "%" -> parseDivisor(s)
lookahead === " " -> parseOperator(s)
-> throwInternalError(writeUnexpectedCharacterMessage(lookahead))

Oh no, the second case is nested. Seems like our simple condition ? result : rule wouldn't work here. But really, our technique is just to think about how we'd format if statements and copy that.

For an if statement, we'd write:

//...
if (lookahead === "[") {
  if (s.scanner.shift() === "]") return s.rootToArray()
  return s.error(incompleteArrayTokenMessage)
}
//...

So let's replace the { with ?, and the } with ::

// prettier-ignore
// ...
lookahead === "[" ?
    s.scanner.shift() === "]" ? s.rootToArray() :
    s.error(incompleteArrayTokenMessage)
:
// ...
"fallback"

Fade between nested early returns and nested ternaries

And here's the whole thing:

// prettier-ignore
export const parseOperator = (s: DynamicState): void => {
  const lookahead = s.scanner.shift()
  return
    lookahead === "" ? s.finalize() :
    lookahead === "[" ?
        s.scanner.shift() === "]" ? s.rootToArray() :
        s.error(incompleteArrayTokenMessage)
    :
    isKeyOf(lookahead, Scanner.branchTokens) ? s.pushRootToBranch(lookahead) :
    lookahead === ")" ? s.finalizeGroup() :
    isKeyOf(lookahead, Scanner.comparatorStartChars) ? parseBound(s, lookahead) :
    lookahead === "%" ? parseDivisor(s) :
    lookahead === " " ? parseOperator(s) :
    throwInternalError(writeUnexpectedCharacterMessage(lookahead))
}

Comparison to Prettier proposal

As a reminder, here's the example for "case-style" ternaries from the Prettier proposal:

// prettier-ignore
const animalName =
  pet.isScary() ? 'wolf'
  : pet.canBark() ? 'dog'
  : pet.canMeow() ? 'cat'
  : 'probably a bunny';

My main issue with this formatting is how it treats the first case and the last (fallback) case.

// prettier-ignore
const animalName =
  pet.isScary() ? 'wolf'  // 🍎
  : pet.canBark() ? 'dog' // 🍎
  : pet.canMeow() ? 'cat' // 🍎
  : 'probably a bunny'; // 🍊

Semantically, the first case is no different than the other cases. Whereas the fallback case is special. But the syntax doesn't reflect that; the leading-: syntax groups all the cases together (including the fallback) not the first case.

For example, try commenting out the first case. Now the code is invalid.

If you agree, vote!

My entire argument can be summarize as:

Don't invent a new way to format ternaries. Instead, do the same structure you'd do for if statements

Prettier is looking for feedback on their "case-style" ternaries proposal, so if you agree with me make yourself heard!