Stream: ideas

Topic: Unindented, implicit else


view this post on Zulip Kevin Gillette (Dec 09 2022 at 09:46):

(forked from https://roc.zulipchat.com/#narrow/stream/231634-beginners/topic/Task.2Ferror.20handling.20.26.20platform.20use/near/314385634)

Although iterating on code can mitigate this somewhat, it can be frustrating to end up in cases where if/else/when cases nest 4+ levels deep. The mitigation often involves using Result.map, Task.onFail, etc, which makes the code more succinct, but at the expense of making it less readable to inexperienced Roc/functional programmers (made more tricky by the naming similarities between onFail, mapErr, etc, and the frequent need, at least for me, to consult type declarations in documentation).

If there are clear-win techniques to minimize this nesting, please let me know!

Otherwise, a contentious proposal: do we really need else?

In imperative languages, such as Python or Go, minimizing indentation is frequently considered a readability boon. Instead of an else (assuming the else would've spanned the remainder of the function), a programmer in those languages may just use a return within an if statement. The cognitive load is decreased because that technique essentially signals "we're done with these variables (or combination of values), and we don't need to worry about this condition/edge-case arising again for the remainder of the function!" The cognitive load aspect doesn't strictly apply to Roc (or strict expression languages) because there can't be anything _after_ an else. However, what is applicable to Roc as well as other languages is that with less indentation, there's a reduced likelihood of horizontal scrolling when viewing source code.

Following the same trend of reducing indentation by allowing implicit assignment without a let, since Roc does not allow any code within a function to exist _after_ an else, can we permit an unpaired if, where the unindented code that follows forms an _implicit_ else?

For example, could these be equivalent? What would be the downsides?

if x then
    x
else
    y = someExpensiveExpr
    if y then
        y
    else
        someOtherExpensiveExpr
if x then
    x
y = someExpensiveExpr
if y then
    y
someOtherExpensiveExpr

(Any de-indentation would imply an early return/result, or viewed another way, the grammar could rewrite a de-indentation to be semantically the same as a re-indentation under an else)

Similarly, could a de-indentation following a when be equivalent to a _ case?

view this post on Zulip Kevin Gillette (Dec 09 2022 at 09:46):

As a side note, if the Roc language could guarantee that expressions will only be computed at the site of their first real consumption, I might be willing to write:

# not computed until the first else-if?
y = someExpensiveExpr
# not computed until the last else?
z = someOtherExpensiveExpr
if x then
    x
else if y then
    y
else
    z

view this post on Zulip Anton (Dec 09 2022 at 10:36):

[...] expressions will only be computed at the site of their first real consumption

I would guess our current (LLVM?) optimizations already do this for simple cases like this, but I'm not sure.

view this post on Zulip Folkert de Vries (Dec 09 2022 at 10:37):

it depends

view this post on Zulip Folkert de Vries (Dec 09 2022 at 10:38):

for expensive computations, if it can prove that it is safe to re-order code, then yes

view this post on Zulip Folkert de Vries (Dec 09 2022 at 10:38):

reordering code in general is not safe though, and I'm not sure how good LLVM is at it (given that usually, in C, it is not valid)

view this post on Zulip Anton (Dec 09 2022 at 11:28):

I also remember wanting to do if x then return y in functional languages. I've given it some thought and I'm currently in favor of adding something like it.My suggestion would be:

if x return
    x

y = someExpensiveExpr
if y return
    y

someOtherExpensiveExpr

To prevent complicating things for the compiler I'd have

if x return
    y

z

desugar to:

if x then
    y
else
    z

In the case that there is no nesting I do stylistically prefer if ... then ... else ... . if ... return ... seems suited for scenarios where you want an early abort and you would otherwise have an else block with several lines of code or more nesting.

view this post on Zulip Folkert de Vries (Dec 09 2022 at 12:04):

this sort of control flow ... is not actually nice in practice. the full if-then-else is usually quite nice, if you get too much nesting, you can extract a quick helper function

view this post on Zulip Folkert de Vries (Dec 09 2022 at 12:05):

early returns are useful in imperative languages with loops and such, and in rust in particular the cost of adding a helper function is very high (usually you need ~10 lines to define the function boilerplate)

view this post on Zulip Folkert de Vries (Dec 09 2022 at 12:05):

in roc/elm/haskell this is not true: making new functions is syntactically cheap

view this post on Zulip Anton (Dec 09 2022 at 12:35):

Good point!

view this post on Zulip Brendan Hansknecht (Dec 09 2022 at 16:12):

This is a really interesting idea where I think i would definitely have to see some code in a few forms. As an abstract, it is really hard to tell.

I definitely have run into cases (especially with recursion/walks and branching that get too nested), but i feel like I have noticed them a lot less lately. Not sure if that is me just getting used to it, being better at writing roc, or working on problems that don't require this nesting too much.

I agree that sometimes it is just a matter of pulling out an extra function, but i do think there are cases where the single use function would just make things harder to follow.

One immediate thought is that nested conditionals can sometimes be flattened by a when on more of your state. Then the nesting goes away in favor of more branches in the when. Of course this isn't always possible, but can be a useful technique.

Backpassing and pipelining as much as possible are other important techniques for keeping things flatter. Don't solve this problem directly, but a flatter base and split up pipelines tends to reduce the amount of nesting needed.

view this post on Zulip Kevin Gillette (Dec 09 2022 at 16:35):

this sort of control flow ... is not actually nice in practice. the full if-then-else is usually quite nice, if you get too much nesting, you can extract a quick helper function
in roc/elm/haskell... making new functions is syntactically cheap

@Folkert de Vries quite true. In some cases though, I ended up with awkward situations where the trade-offs are "do i want 5 levels of nesting or 4 helper functions?" If choosing the 4 helper functions, that brings us back to "naming things are hard." Defining those helpers as local assignments within the function they help can help simplify naming, but at the expense of distracting from the core purpose of the outer function.

view this post on Zulip Kevin Gillette (Dec 09 2022 at 16:41):

for expensive computations, if it can prove that it is safe to re-order code, then yes

It should be relatively easy for us to transform our own IR or AST to provide that as a language guarantee (or best effort) easily during compilation, especially since Roc is pure. I've seen that kind of thing done in more complex languages as part of static single assignment analysis. If llvm _then_ reorders the computation to be earlier as an optimization, so be it.

view this post on Zulip Kevin Gillette (Dec 09 2022 at 16:46):

My suggestion would be:

if x return
    x

@Anton I was wondering about how to make the control flow implications more clearly signaled beyond just indentation. I believe your suggestion makes it unambiguous, and like your syntax :)

In the case that there is no nesting I do stylistically prefer if ... then ... else ...

Agreed entirely. When deep nesting isn't an issue, visual balance is a better goal

view this post on Zulip Kevin Gillette (Dec 09 2022 at 17:05):

i feel like I have noticed them a lot less lately. Not sure if that is me just getting used to it, being better at writing roc, or working on problems that don't require this nesting too much.
but i do think there are cases where the single use function would just make things harder to follow.

@Brendan Hansknecht having 4+ ways to manage indentation trade-offs makes this aspect of working with the this aspect of the language feel "artisanal" to me: spending time substantially resculpting code (refactoring), not to remove limitations or add capabilities, but merely to improve how it looks while doing the exact same thing as before.

That might be a nice property or not depending on the goals of the language or programmer, but Roc seems like one of the few recent engineering-minded (rather than research minded) FP languages, and in engineering languages, fewer tools that work better is often considered preferable to having more tools which are specialized or interchangeable.

Since these different flavors (nesting vs piping vs backpassing vs richer when) are all structurally quite different, switching between them is time consuming and can be error prone. If the editor offers to rewrite between equivalences, and especially if that can be supported by an LSP implementation, it would be much less of an issue, though still not ideal.

view this post on Zulip Kevin Gillette (Dec 09 2022 at 17:13):

One immediate thought is that nested conditionals can sometimes be flattened by a when on more of your state.
Backpassing and pipelining as much as possible are other important techniques for keeping things flatter.

These have worked well for me in many cases, though where i still struggle is with error handling. In such cases, await may need to become attempt, and merging errors together to handle them all at once is still something I need to learn. Ideally, within a single function, I'd like to be able to succinctly backpass or pipeline successes while collecting all errors, and then handle errors at the end of the function. I know how to do this across two functions, but not within one.

view this post on Zulip Brendan Hansknecht (Dec 09 2022 at 17:22):

I think this is more a matter of developing the default style for Roc and developers getting used to Roc. I don't really think pipelining vs backpassing vs control flow will often get interchanged. Sure as a newer user, someone may write in a linear fashion that could use pipelines, but I don't think switching generally is a problem. I will say the one exception to this rule is if..then swappin out for when. Those both have heavy potential overlap.

As for error flow I think it is mostly a matter of splitting out a variable (This is generally needed when using different functions with backpassing):

main =
    task =
        x <- ... |> Task.await
        y <- ... |> Task.await
        z <- ... |> Task.await
       # calculate something
        Stdout.line ...

    when Task.attempt task is
        Ok _ -> Task.succeed {}
        Err err ->
            # we need tag to task to make this print out nicely. Otherwise a helper that uses the error tag to convert to a pretty message
            crash "hit some sort of error"

Theoretically instead of Task.attempt at the end, you could do something like:

    err <- Task.onFail task
    crash "hit some sort of error"

That would make it even shorter. Just not 100% sure it works/the exact types because I haven't tested.

view this post on Zulip Joshua Warner (Dec 09 2022 at 21:45):

I've run into plenty of code that legitimately is clearer with early returns. Something like:

if complexCondition1 then
  # ... do some stuff ...
  if complexCondition2 then
    # ... do some stuff ...
    if complexCondition3 then
      # ... do some stuff ...
    else
      Err Condition3Failed
  else
    Err Condition2Failed
else
  Err Condition1Failed

It'd be nice if the Err Condition1Failed could be brought next to the if complexCondition1, and for there to be less indentation.

view this post on Zulip Joshua Warner (Dec 09 2022 at 21:46):

Sometimes that chain of if/else gets so long that you have to scroll down to see what the matching else is, and sometimes it's hard to match up correctly.

view this post on Zulip Joshua Warner (Dec 09 2022 at 21:47):

In this example, it would kinda suffice to invert the condition - but you still have the indentation problem. Plus, that doesn't work if you instead of a sequence of nested when statements that are binding variables.

This is where let Some(x) = abc else { return Err(...) }; can come in handy in rust.

view this post on Zulip Joshua Warner (Dec 09 2022 at 21:48):

Breaking things out into other functions is also not a panacea, since it becomes even harder then to hold the whole thing in your head and reason about it.

view this post on Zulip Joshua Warner (Dec 09 2022 at 21:48):

When things are broken out into functions, you lose all sense of ordering between them - and you have to mentally nit them together again based on matching the function names.

view this post on Zulip Joshua Warner (Dec 09 2022 at 21:50):

Compare that with a sequence of let ... else in rust, where the textual ordering actually matches the execution order.

view this post on Zulip Kevin Gillette (Dec 10 2022 at 00:17):

That example can be linearized at least (continuous indent rather than accordion indent) by inverting each condition.

I find code is always easier to read and needing less mental load/context, in any language, when putting short branches first, rather than what i often see, which is a 30 line if branch followed by a dangling 3 line else branch.

view this post on Zulip Richard Feldman (Dec 10 2022 at 01:17):

has anyone experimented with what code would look like if you just didn't indent after else?

view this post on Zulip Richard Feldman (Dec 10 2022 at 01:18):

after all, today the else expression always ends in 2 outdents

view this post on Zulip Richard Feldman (Dec 10 2022 at 01:19):

so it's not like it would be any harder to tell where it began and ended if there were just 1 outdent instead

view this post on Zulip Richard Feldman (Dec 10 2022 at 01:20):

I'd recommend trying to convert some .roc files to that style (e.g. from Advent) and post them here so we can see what they look like side by side - and then compare to other syntax ideas like if ... return

view this post on Zulip Joshua Warner (Dec 10 2022 at 04:26):

@Kevin Gillette - yep, indeed. I was thinking more about a world that has some form of if let, where you're binding variables and you can't just invert the condition. Roc doesn't have that (yet? I think?) - but perhaps something to consider.

view this post on Zulip Kevin Gillette (Dec 10 2022 at 15:18):

@Joshua Warner I'll be interested to see that proposal. Go has a similar feature:

if x := someExpensiveCall(); x >= 23 {
    ...
}

x will then remain in scope for every part of the if and any else-if-elses that follow in the same chain.

When that language first came out, I was a much less restrained/considered programmer than today, and figured that if a feature like this exists, it should be used as often as possible.

However, I eventually came to conclude that, in Go, the feature should almost never be used because the assignment risks pushing the condition off the page (many programmers are sadly uninhibited by concerns like "line length"), and after all, the condition is the important part of a conditional.

I have seen erroneous code sneak through code reviews and debugging sessions because of misassumptions, equivalent to:

if dangerousTask, shouldProceed := longCall(with, many, arguments); !shouldProceed {
    dangerousTask()
}

Pretend the conditional (and particularly the accidental ! would require vertical scrolling to see).

view this post on Zulip Kevin Gillette (Dec 10 2022 at 15:35):

Certainly Roc would have different tradeoffs that may make if let more intrinsically useful in this language than the above syntax is in Go.

That said, I still would have concerns about line length when mixing assignments and conditions, and so if we wanted to adopt if let, I'll preemptively counter-propose if where:

if width > 5 then where width = x2 - x1
    width / 2

It could also be split onto multiple lines:

if width > 5 then
    where width = x2 - x1
    width / 2

I would've put the then after the where clause, but in the context of a potential if return (which this is combinable with), the return keyword, being a very important visual signal, gets as much opportunity as possible to remain on-screen without vertical scrolling.

The assignments/bindings are only of secondary importance and could fall off-screen. The bindings are like the ingredients on a food package: they have relevance, but you don't look at them to find out if the item is expired.

view this post on Zulip Tommy Graves (Dec 10 2022 at 16:46):

In a pure functional language, else seems totally unnecessary. The reason why else (and early returns) is useful in imperative languages is because you need to distinguish between
(1)

if cond then
  performSomeEffect()
end

performSomeOtherEffect()

and (2)

if cond then
  performSomeEffect()
else
  performSomeOtherEffect()
end

In a language like Roc, there's no equivalent, because if - then - else always has to be the last-evaluated part of a block. There's no need to distinguish between (1) and (2) because (1) doesn't exist -- you can't just "do something" inside of an else and then continue on after the else.

So my radical recommendation is to replace the else keyword with end (or fi, fin, etc.) and, like Richard was wondering, have the else case indented one fewer level. (I think this requires that defs don't get reordered, though.)

view this post on Zulip Anton (Dec 10 2022 at 17:02):

I'm not attached to the else keyword but I think that for simpler if's without nesting it is nicer for both blocks (inside if and inside else) to have the same indentation.

view this post on Zulip Joshua Warner (Dec 10 2022 at 18:17):

Random idea: what if instead of inverting the condition, you could invert the order of the branches, like so:

if cond else Err(InvalidInput)
then
<... unindented body that handles the happy-path>

view this post on Zulip Anton (Dec 10 2022 at 18:23):

This is conceptually very similar to:

if !cond return
   Err(InvalidInput)

<happy-path...>

view this post on Zulip Richard Feldman (Dec 10 2022 at 19:22):

my concern with the keyword return specifically is that I can already see the volume of beginner questions about "why doesn't return actually return from the function when it's inside another conditional?"

view this post on Zulip Kevin Gillette (Dec 10 2022 at 20:15):

Richard Feldman said:

my concern with the keyword return specifically is that I can already see the volume of beginner questions about "why doesn't return actually return from the function when it's inside another conditional?"

Do you mean, "why can't we use return like in a typical imperative language?"

break or yield may have fewer convey equivalent meaning with fewer inter-language mis-inferences.

view this post on Zulip Richard Feldman (Dec 10 2022 at 20:19):

I mean the idea of if ... return being syntax sugar for differently-indented if/else

view this post on Zulip Anton (Dec 11 2022 at 09:14):

yield seems like the best alternative although its meaning is a lot less well known for non-native English speakers.

view this post on Zulip Brendan Hansknecht (Dec 12 2022 at 17:11):

Just got an email related to testing that labeled these statements as guard clauses. So maybe we could do some sort of if guard if we want to avoid existing common names like return and yield.

view this post on Zulip Anton (Dec 12 2022 at 17:17):

I don't love guard but it's a good contender

view this post on Zulip Kevin Gillette (Dec 26 2022 at 08:07):

https://youtu.be/CFRhGnuXG-4

view this post on Zulip Anton (Dec 26 2022 at 10:37):

Good video, this made me think of another possible syntax:

if cond abort
   Err(InvalidInput)

<happy-path...>

view this post on Zulip Kevin Gillette (Dec 26 2022 at 15:54):

Perhaps, though something to keep in mind: https://inclusivenaming.org/word-lists/tier-1/

view this post on Zulip Kevin Gillette (Dec 26 2022 at 15:57):

That doesn't seem especially distinguished from if ... return, and while return is neutral on what you use it for and why, this implies (based on word choice) that it's stylistically to be used just for failure cases.

view this post on Zulip Kevin Gillette (Dec 26 2022 at 15:58):

In practice, Result.map alongside backpassing already handles (implicitly) such a failure case methodology pretty well

view this post on Zulip Anton (Dec 26 2022 at 16:01):

Uhu, I guess yield would be my first preference then

view this post on Zulip Ayaz Hafiz (Dec 26 2022 at 16:06):

What relationship would this have with def ordering and shadowing? Like if you turn

if cond then
  msg = computeMsg ...
  msg
else
  <long computation>
  msg = ...
  msg

to

if cond return
  msg = computeMsg ..
  msg

<long computation>
msg = ...
msg

do you now get a shadowing error? That seems worse than having the if-then-else behavior. Alternatively, if the de-indented part after the early return is considered the else-branch then you have to have a different semantics for def-ordering than is currently provided (even in the world where re-ordering is a warning) right

view this post on Zulip Kevin Gillette (Dec 26 2022 at 16:09):

That shouldn't count as shadowing if we say Roc has lexicographic scoping, since, at that point, the first msg is defined in an inner scope that ends before the second msg scope has a chance to exist.

At best that'd be "reverse shadowing" ?

view this post on Zulip Ayaz Hafiz (Dec 26 2022 at 16:10):

Well, this is shadowing today:

f =
  x = 1
  x + 1

x = 2

because Roc re-orders definitions

view this post on Zulip Kevin Gillette (Dec 26 2022 at 16:11):

but those are top level. your cond example must be in a function

view this post on Zulip Anton (Dec 26 2022 at 16:11):

do you now get a shadowing error? That seems worse than having the if-then-else behavior. Alternatively, if the de-indented part after the early return is considered the else-branch then you have to have a different semantics for def-ordering than is currently provided (even in the world where re-ordering is a warning) right

The idea was that:

if cond yield
    Err(InvalidInput)

something

Would be syntactic sugar for:

if cond then
    Err(InvalidInput)
else
    something

If it's only syntactic sugar we wouldn't have any additional problems right?

view this post on Zulip Ayaz Hafiz (Dec 26 2022 at 16:13):

Kevin Gillette said:

but those are top level. your cond example must be in a function

The same goes for nested defs in a function. But this an aside

view this post on Zulip Ayaz Hafiz (Dec 26 2022 at 16:14):

I see, that resolves my question Anton, thanks. So in that case you would just have to keep in mind that definitions after the yield/early return would not be reordered

view this post on Zulip Kevin Gillette (Dec 26 2022 at 16:21):

I see. It makes sense that definitions within the same scope could be semantically reordered, but it seems chaotic to me that definitions throughout a scope tree could be reordered.

There's of course room for that in the optimization space as long as the result is well behaved (conforms to the language rules).


Last updated: Jun 16 2026 at 16:19 UTC