(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?
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
[...] 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.
it depends
for expensive computations, if it can prove that it is safe to re-order code, then yes
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)
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.
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
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)
in roc/elm/haskell this is not true: making new functions is syntactically cheap
Good point!
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.
this sort of control flow ... is not actually nice in practice. the full
if-then-elseis 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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
Compare that with a sequence of let ... else in rust, where the textual ordering actually matches the execution order.
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.
has anyone experimented with what code would look like if you just didn't indent after else?
after all, today the else expression always ends in 2 outdents
so it's not like it would be any harder to tell where it began and ended if there were just 1 outdent instead
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
@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.
@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).
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.
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.)
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.
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>
This is conceptually very similar to:
if !cond return
Err(InvalidInput)
<happy-path...>
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?"
Richard Feldman said:
my concern with the keyword
returnspecifically is that I can already see the volume of beginner questions about "why doesn'treturnactually 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.
I mean the idea of if ... return being syntax sugar for differently-indented if/else
yield seems like the best alternative although its meaning is a lot less well known for non-native English speakers.
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.
I don't love guard but it's a good contender
Good video, this made me think of another possible syntax:
if cond abort
Err(InvalidInput)
<happy-path...>
Perhaps, though something to keep in mind: https://inclusivenaming.org/word-lists/tier-1/
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.
In practice, Result.map alongside backpassing already handles (implicitly) such a failure case methodology pretty well
Uhu, I guess yield would be my first preference then
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
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" ?
Well, this is shadowing today:
f =
x = 1
x + 1
x = 2
because Roc re-orders definitions
but those are top level. your cond example must be in a function
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?
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
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
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