starting a separate thread to discuss this idea based on:
Agus Zubiaga said:
I think another option would be to replace
?with a Zig/Swift-styletrykeywordtry readFile! "file.txt"
Agus Zubiaga said:
I think I prefer
tryover?in general:
- it doesn’t make the call look like a question (does it return a bool like it would in Ruby?)
- it makes it easy to spot where control flow happens
- all other control flow constructs use a keyword
- people already associate
trywith errors- it’s easier to google
- Swift and Zig already use this exact syntax
- it always goes in the same place whether it’s a call or a
Resultvalue
something I like about both try File.read! and File.read!? is that it means ! can be a naming convention (enforced by compiler warning) instead of a semantic suffix
which is nice in that it's easier to explain how it works and easier to understand how it works ("you know names, right? It's part of the name")
something I hadn't considered about try is this aspect:
Sam Mohr said:
I think either of these options could work just like how
dbghas been made to be pipeline-able:jsonContent = File.read! "text" |> try |> Json.decode
a cool thing about this design is that it means we wouldn't need a new ?> operator
because if try takes an expression, then this Just Works:
try File.read! "text" |> Result.mapErr ReadFailed
but also you can still do File.read! "text" |> try |> ... if that's the behavior you want
my overall feeling with these two is that:
try is more verbose in scripts but presumably fine everywhere else, also it apparently has precedent in both Zig and Swift (I didn't know about it in Swift!) and means we wouldn't need a separate ?> operator? is more concise but means !? is a thing, and I have really - I swear - tried very hard to become okay with it, and so far I have not been able to move the needle out of the zone of "extremely strong visceral negative reaction every time I see it"I think that try should be just as good, just as long as we can get it to take as many lines as ? does. If I have to add a |> try for 3 things in a pipeline, then I go from 3 lines to 6. And if it makes everyone happy, then I'm happy
that's definitely doable because you can use parens
e.g.
(try File.read! path)
|> whatever
What about:
jsonItem =
((try Http.get! "http://api.com")
|> try Json.decode)
|> List.get (try Str.toU64 strIndex)
I wouldn't do that haha
Neither would I...
I would just take the extra line:
jsonItem =
(try Http.getUtf8! "http://api.com")
|> Json.decode
|> try
|> List.get (try Str.toU64 strIndex)
I know that it adds special parsing in pipelines compared to normal code, but allowing |> try ... to desugar to |> Result.try ... \ok -> would be just as compact in my eyes.
jsonItem =
(try Http.getUtf8! "http://api.com")
|> try Json.decode
|> List.get (try Str.toU64 strIndex)
But maybe it's my knowledge of what we have with ? that makes me dislike the standalone |> try line.
Not a dealbreaker, but it means that this is basically just ? and makes the decision easy
oh interesting
that looks fine to me actually
Well then
Sign me up for try!
and I think this could still work in that design
try File.read! "text" |> Result.mapErr ReadFailed
Not only would that work, but we could still get ?> in pipelines:
res =
File.read! "text"
|> try Result.mapErr ReadFailed
|> Json.decode
|> try Result.mapErr DecodeErr
Not sure how readable that is to a newcomer, I think I'm desugaring and like it for that reason
I like how this reads:
tokenStr = try
Request.header "Token"
|> Result.mapErr \HeaderNotFound -> MissingTokenHeader
{ claims } = try
Jwt.parse tokenStr jwtSecret
|> Result.mapErr InvalidJwt
contents = try
File.readUtf8! path
|> Result.mapErr FileReadFailed
I like how it separates the "here is the thing that might fail |> here is what I want to do with the Result it returns" into its own visually self-contained expression
and yet it's also clear where the control flow change is
Those trys apply to the entire pipeline?
What would happen without a newline after the try? Same behavior? Bug?
And how would it deal with two tries in one expression?
I'm thinking of it being the same as expect - that is, you give it an expression that can be single-line or multiline
try foo bar try baz should be an error I think
if you want to nest them I think it should require parens or |>
e.g.
try foo bar (try baz)
or
try foo bar
|> try baz
Richard Feldman said:
e.g.
try foo bar (try baz)or
try foo bar |> try baz
These two should not give the same result right? Just to be sure
right, just like with |> and parens in function calls (which would also be different) - I just wanted to illustrate two ways of nesting - not two equivalent expressions :big_smile:
Richard Feldman said:
I would just take the extra line:
jsonItem = (try Http.getUtf8! "http://api.com") |> Json.decode |> try |> List.get (try Str.toU64 strIndex)
Nit and a question:
The try Str.toU64 strIndex should happen first, i.e. in an earlier assignment, since cheap data validation should pretty much always happen before doing expensive network i/o to avoid wasting a round trip. It doesn't matter how much you want to avoid naming your index: it's just a better tradeoff to do the ultra cheap stuff first.
Question: why does try Http.getUtf8! "http://api.com" need to be parenthesized? If it doesn't, would the formatter be able to remove the parentheses?
Other question: if try would work in pipeline fashion, would try work anywhere else a first-class function could be used, such as the following?
listOfResults |> List.map try
Question: why does
try Http.getUtf8! "http://api.com"need to be parenthesized? If it doesn't, would the formatter be able to remove the parentheses?
@Kevin Gillette the reason this is parenthesized is because Richard wants try to be lower precedence than |> so that we can do stuff like
text =
try File.read! "file.txt" |> Result.mapErr FailedToReadFile
# Think of this like the below
# try (File.read! "file.txt" |> Result.mapErr FailedToReadFile)
without anything special being required. I agree that this way is more readable to a new user than
text =
File.read! "file.txt"
|> try Result.mapErr FailedToReadFile
because now the try isn't matched up with the actual fallible operation, but the error wrapping operation that follows it.
However, this now means that we can't get can't the same "Just Do It" benefit that ! gives us: I can currently pipeline effectful operations and the code declaratively reads what I'm doing, while effects happen in the background:
decodedData =
Http.get! "api.com"
|> Http.streamBody!
|> Json.parseStream!
This is not quite the case with ? in the presence of error wrapping, even today:
content =
File.read! "file.txt"
|> Result.mapErr? FailedToReadFile
Like with the above try example, the error deferment/propagation happens as it should after wrapping the potential read error in FailedToReadFile, but now the code is less obviously deferring errors from File.read!, and more looks to be deferring errors that occur while wrapping errors.
The benefit with low precedence try is that we can sacrifice some ease of use in pipelining, and as a result get more readable code.
contents = try
File.readUtf8! path
|> Result.mapErr FileReadFailed
I'd love to find a mechanism that does both. It seems like some ?> operator that maps and returns errors would do the job here, but ? being removed makes ?> less obvious.
I think try is a good idea as long as we syntax highlight it and mess around with precedence and formatting a bit. I bet it will quickly look just fine.
yeah try feels like the frontrunner design to me of the (many!) options we've discussed :big_smile:
I like that it makes the |> Result.mapErr case work nicely without requiring an additional operator
and I like the "! is a naming convention the compiler enforces but there are no semantic rules to learn; it's just a name" design, and also the "it's obvious how to get an actual Result" part
It means fewer "weird" operators, yes
Low precedence try feels a bit surprising to me if it can also be piped
If it can't be piped, it's more readable, but less ergonomic.
If it can be piped, it kinda runs into the same issue that polymorphic ! does, which is that it works differently in different contexts.
My intuition would be that anything that looks like a keyword should have higher precedence than |>. As a casual reader, I would definitely expect try a |> b to be equivalent to (try a) |> (b), not try (a |> b)
What about try: some expr instead of try some expr ?
Kevin Gillette said:
My intuition would be that anything that looks like a keyword should have higher precedence than
|>.
That’s not the case with if and when which I think are the only other keywords that appear before an expression
Oh, there’s also dbg and expect, but they also work the same
ah, that is true. if and when are hopefully not often followed by long, multiline pipelines. I'd personally probably use parens, or variable-assignment hoisting for visual clarity when using pipelines in those cases.
Notably I'm someone who almost never uses syntax highlighting, since in most languages, the use of symbols, precedence rules, and formatted line breaks, unambiguously aligns with my intuition (either I'm well conditioned by those languages, or I've gotten pretty lucky in that respect). If it ever becomes widely available, I'd use color and style instead for something like Douglas Crockford's notion of giving a different color to each scope, or a different color to each variable, maybe underlining the hovered/cursored variable, and bolding any expression which contributes to its value).
Do dbg and expect bind more or less tightly than |> ?
i'm hugely in favor of this proposal it solves a lot of the issues from previous proposals and just looks really nice.
@Sky Rose could you weigh in on https://github.com/roc-lang/roc/issues/7087? I think you might disagree with the desugaring rules, it'd be good to make sure people are happy with the proposed behavior before someone implements this
I didn't quite think through everything thoroughly, but it seems like all the conditions will make it hard to predict what will happen. Some examples:
tryused in either theiforelsebodies will render the body as a Result if the body has any definitions, and propagate the error if there are NO definitions
if noDefinitions then
try result
else
result = result
try result
As written, these two branches would behave differently, even though they seem like they should be the same.
If the expression is a
|>pipeline:
Iftryis used in the middle of a pipeline, it is propagated to the result of the pipeline
Iftryis used at the end of a pipeline, it is propagated to the parent of the pipeline
result
|> try
result
|> try
|> identity
As written, these two pipelines would behave differently, even though adding identity to a pipeline shouldn't affect anything.
I would hope for rules that depend less on everything around the try (like whether there's something after it in the pipeline, or whether the function has a definition elsewhere). Depending on where the try is is fine (whether it's in an if, anywhere in a pipeline, or in a definition).
the more we discuss things like this the more I think it should just be early return haha
the rule is unbeatably simple
Yeah, that identity case is a sucker
it's like having an entire chapter on how try desugars vs an entire sentence
And we don't have to expose an early return to users, we just need it under the hood
We always still have Result.try, though we should probably expose Result.try! as well.
I think writing local expressions in the expression tree that is defs and ret vals of a function is still easily doable with an early return approach, it's just not the default
But yes, literally everything now would come down to "returns to the function barrier"
I think if we have try doing an early return, we might as well have an actual return
seems unnecessarily inconvenient to have one but not the other, if we already have one keyword that affects control flow in that way
Okay, sure. If we do that, I think we should probably move towards a if-return syntax instead of the indent avoiding if-else we just added
It's more keywords in the language to have return, but it's not a prime name for functions or variables, and literally everyone knows how it works
and there have been several separate explicit requests for it, e.g. #ideas > early return statement
however, that thread also had some unresolved concerns about a return statement.
those concerns apply equally to try doing an early return, which is why I think it makes sense to have either an explicit return statement and try be sugar for conditionally doing a return, or else have neither (in which case we have the concerns from this thread)
Yep
currently it's feeling to me like the concerns in this thread are a bigger deal - it feels like we already have a problem with it being unclear exactly what try does in several different scenarios, and nobody has even tried writing real code with it yet because it's still in the design phase :sweat_smile:
As such, it feels like committing to return plus try would mean people know where the ground is under their feet, and would be able to write anything at all
summary of tradeoffs:
try"return says it can go all the way to the enclosing function, but there's an argument for that bound being too far, and that instead it should only be able to affect control flow in some maximum number of enclosing expressions.return independent of try considerations, and our attempt to address those use cases with #ideas > "early returns" via formatter has had unintended negative consequences (and might not turn out to be a viable solution)return is sometimes genuinely the clearest way to write a piece of code, even though its existence in the language would mean that expressions are less isolated when it comes to control flowreturn to exit early from the middle of a nested conditional (for example), and giving them access to that way of writing things reduces Roc's learning curvereturn, so introducing it reclaims some strangeness budgetthis thread is persuading me that return is a good idea, because:
return is that we can't tell (without scanning through it) whether certain sufficiently nested expressions might be affecting control flow of an outer expressionLesser of two evils, yes
If we took a single rule like try always wraps to the current block instead of the current function (like early return), isn't that a simple enough rule that isn't confusing?
I feel like something as simple as that rule should be enough without losing the clean expression tree
Can we then use pipelines that end with try?
I don't think "current block" can work; then if / else branches couldn't escape, e.g.
if foo then
try File.delete! path
Ah yeah, back to the needing to try the control flow
yeah exactly
If we adding return that also means we are:
I'd vote yes to both
Especially since we have a buggy implementation of the "early return" if-else right now
Yes
Even if a solid part of me still feels like early returns are giving up some nice guarantees and readability benefits (less so function scope try since it is only for error cases)... I definitely agree that it is the simplest and most understandable way to add a handful of features.
Brendan Hansknecht said:
- Adding warning for unreachable code after return
and crash, while we're at it :sweat_smile:
presumably the same rules there
So... I'm gonna make a return keyword GitHub issue, and then update the try rules in #7087 to desugar to:
when result is
Err err -> return Err err
Ok val ->
... use val
might want to mention in the return one that we'll need to introduce a transformation into SSA form for those at some point either during or right before monomorphization
I was really worried about how it would interact with tasks
This is another good point
currently we're automatically SSA but return wouldn't be
I think I can see why
Created the early return issue: https://github.com/roc-lang/roc/issues/7104
And updated the try issue: https://github.com/roc-lang/roc/issues/7087
how does early return impact SSA? or do you mean you want to transform it so the IR does not have early returns and only one return point?
I think it's that, a single return makes things easier
Though I'm not sure how that will integrate with our FFI continuations
Ayaz Hafiz said:
how does early return impact SSA? or do you mean you want to transform it so the IR does not have early returns and only one return point?
I think LLVM requires the IR to be like that
llvm has a return instruction I think. So I think it is free from ssa.
But it is probably best practice to jump to a single exit point if they have the same cleanup.
hm I thought it had to be a phi node thing... :thinking:
Oh yeah...my bad
Yeah, they all have to merge to a single block and return.
One of the great niceties of mlir is doing away with phi nodes.
Phi is only for conditionals that merge into the same control flow later right? but early returns in llvm are fine
according to https://mapping-high-level-constructs-to-llvm-ir.readthedocs.io/en/latest/control-structures/ssa-phi.html they aren't allowed
(and the solution is SSA into phi node)
but I guess dev backend can more directly do an early return?
as in like "set return register, adjust stack pointer, jump"
hmm yeah i mean you can do it this way but you don't need to
you can just ret the return value and it's fine
I would need to just test it, but based on a thread I was skimming on the llvm discourse, the phi node to return in a single block is required by llvm
Or well, can also use an alloca and just jump to the final block and return, but the single return in general
https://godbolt.org/z/be1fnx7W9
Oh cool. I wonder when that was added. Probably a long time ago. I bet I was looking at long dead forum posts.
i think a long time ago too. i mean early returns are key in assembly, you don't want to be jumping everywhere. so supporting it in the IR is key too
I guess the only comment is that it is still nice to share cleanup if many different branches have to deal with refcounting for example, but not a big deal (and if they match close enough, llvm may figure it out)
Just double checked the forum posts, they are mid 2017/18, but I misread them. It is for multiple returns with different return addresses.
ah yeah ok
Also, it is kinda interesting how most frontend seem to avoid phi nodes in general. Instead they tend to use alloca with load and store.
I am a bit afraid of return
If I have something like this:
foo = \arg ->
i = if arg != 0 then arg else return 0
...
and then later, I realise, that the i definition needs an argument:
foo = \arg ->
i = \n -> if arg != n then arg else return n
...
how would this change the control flow? Would the first version return the foo function and the second only return the anonymous function?
Until now, I thought, that to define something as a constant is the same as defining it as a function, that takes no arguments. Is this still true in roc with return?
Last updated: Jun 16 2026 at 16:19 UTC