Stream: ideas

Topic: `try` keyword instead of `?` suffix


view this post on Zulip Richard Feldman (Sep 11 2024 at 03:30):

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-style try keyword

try readFile! "file.txt"

Agus Zubiaga said:

I think I prefer try over ? in general:

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:31):

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

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:32):

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")

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:32):

something I hadn't considered about try is this aspect:

Sam Mohr said:

I think either of these options could work just like how dbg has been made to be pipeline-able:

jsonContent =
    File.read! "text"
    |> try
    |> Json.decode

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:33):

a cool thing about this design is that it means we wouldn't need a new ?> operator

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:34):

because if try takes an expression, then this Just Works:

try File.read! "text" |> Result.mapErr ReadFailed

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:34):

but also you can still do File.read! "text" |> try |> ... if that's the behavior you want

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:37):

my overall feeling with these two is that:

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:40):

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

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:40):

that's definitely doable because you can use parens

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:41):

e.g.

(try File.read! path)
|> whatever

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:42):

What about:

jsonItem =
    ((try Http.get! "http://api.com")
    |> try Json.decode)
    |> List.get (try Str.toU64 strIndex)

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:43):

I wouldn't do that haha

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:43):

Neither would I...

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:43):

I would just take the extra line:

jsonItem =
    (try Http.getUtf8! "http://api.com")
    |> Json.decode
    |> try
    |> List.get (try Str.toU64 strIndex)

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:46):

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.

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:47):

Not a dealbreaker, but it means that this is basically just ? and makes the decision easy

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:47):

oh interesting

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:48):

that looks fine to me actually

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:48):

Well then

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:48):

Sign me up for try!

view this post on Zulip Richard Feldman (Sep 11 2024 at 03:49):

and I think this could still work in that design

try File.read! "text" |> Result.mapErr ReadFailed

view this post on Zulip Sam Mohr (Sep 11 2024 at 03:55):

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

view this post on Zulip Richard Feldman (Sep 11 2024 at 04:58):

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

view this post on Zulip Richard Feldman (Sep 11 2024 at 05:00):

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

view this post on Zulip Richard Feldman (Sep 11 2024 at 05:01):

and yet it's also clear where the control flow change is

view this post on Zulip Brendan Hansknecht (Sep 11 2024 at 05:16):

Those trys apply to the entire pipeline?

view this post on Zulip Brendan Hansknecht (Sep 11 2024 at 05:16):

What would happen without a newline after the try? Same behavior? Bug?

view this post on Zulip Brendan Hansknecht (Sep 11 2024 at 05:17):

And how would it deal with two tries in one expression?

view this post on Zulip Richard Feldman (Sep 11 2024 at 11:12):

I'm thinking of it being the same as expect - that is, you give it an expression that can be single-line or multiline

view this post on Zulip Richard Feldman (Sep 11 2024 at 11:13):

try foo bar try baz should be an error I think

view this post on Zulip Richard Feldman (Sep 11 2024 at 11:13):

if you want to nest them I think it should require parens or |>

view this post on Zulip Richard Feldman (Sep 11 2024 at 11:14):

e.g.

try foo bar (try baz)

or

try foo bar
|> try baz

view this post on Zulip Kilian Vounckx (Sep 11 2024 at 11:17):

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

view this post on Zulip Richard Feldman (Sep 11 2024 at 11:30):

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:

view this post on Zulip Kevin Gillette (Sep 11 2024 at 12:58):

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?

view this post on Zulip Kevin Gillette (Sep 11 2024 at 13:04):

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

view this post on Zulip Sam Mohr (Sep 11 2024 at 17:40):

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.

view this post on Zulip Sam Mohr (Sep 11 2024 at 17:43):

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!

view this post on Zulip Sam Mohr (Sep 11 2024 at 17:47):

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.

view this post on Zulip Sam Mohr (Sep 11 2024 at 17:50):

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.

view this post on Zulip Brendan Hansknecht (Sep 11 2024 at 18:17):

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.

view this post on Zulip Richard Feldman (Sep 11 2024 at 18:21):

yeah try feels like the frontrunner design to me of the (many!) options we've discussed :big_smile:

view this post on Zulip Richard Feldman (Sep 11 2024 at 18:21):

I like that it makes the |> Result.mapErr case work nicely without requiring an additional operator

view this post on Zulip Richard Feldman (Sep 11 2024 at 18:23):

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

view this post on Zulip Sam Mohr (Sep 11 2024 at 18:24):

It means fewer "weird" operators, yes

view this post on Zulip Kevin Gillette (Sep 11 2024 at 20:11):

Low precedence try feels a bit surprising to me if it can also be piped

view this post on Zulip Sam Mohr (Sep 11 2024 at 20:18):

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.

view this post on Zulip Kevin Gillette (Sep 11 2024 at 20:22):

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)

view this post on Zulip Kevin Gillette (Sep 12 2024 at 06:17):

What about try: some expr instead of try some expr ?

view this post on Zulip Agus Zubiaga (Sep 12 2024 at 12:54):

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

view this post on Zulip Agus Zubiaga (Sep 12 2024 at 12:56):

Oh, there’s also dbg and expect, but they also work the same

view this post on Zulip Kevin Gillette (Sep 12 2024 at 13:25):

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).

view this post on Zulip Kevin Gillette (Sep 12 2024 at 13:26):

Do dbg and expect bind more or less tightly than |> ?

view this post on Zulip Andrea Bueide (Sep 13 2024 at 14:14):

i'm hugely in favor of this proposal it solves a lot of the issues from previous proposals and just looks really nice.

view this post on Zulip Sam Mohr (Sep 18 2024 at 09:03):

@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

view this post on Zulip Sky Rose (Sep 18 2024 at 13:08):

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:

try used in either the if or else bodies 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:
If try is used in the middle of a pipeline, it is propagated to the result of the pipeline
If try is 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).

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:00):

the more we discuss things like this the more I think it should just be early return haha

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:00):

the rule is unbeatably simple

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:01):

Yeah, that identity case is a sucker

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:01):

it's like having an entire chapter on how try desugars vs an entire sentence

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:02):

And we don't have to expose an early return to users, we just need it under the hood

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:06):

We always still have Result.try, though we should probably expose Result.try! as well.

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:07):

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

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:08):

But yes, literally everything now would come down to "returns to the function barrier"

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:16):

I think if we have try doing an early return, we might as well have an actual return

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:16):

seems unnecessarily inconvenient to have one but not the other, if we already have one keyword that affects control flow in that way

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:26):

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

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:27):

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

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:50):

and there have been several separate explicit requests for it, e.g. #ideas > early return statement

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:52):

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)

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:53):

Yep

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:53):

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:

view this post on Zulip Sam Mohr (Sep 18 2024 at 15:57):

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

view this post on Zulip Richard Feldman (Sep 18 2024 at 15:59):

summary of tradeoffs:

view this post on Zulip Richard Feldman (Sep 18 2024 at 16:03):

this thread is persuading me that return is a good idea, because:

view this post on Zulip Sam Mohr (Sep 18 2024 at 16:04):

Lesser of two evils, yes

view this post on Zulip Brendan Hansknecht (Sep 18 2024 at 16:17):

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?

view this post on Zulip Brendan Hansknecht (Sep 18 2024 at 16:18):

I feel like something as simple as that rule should be enough without losing the clean expression tree

view this post on Zulip Sam Mohr (Sep 18 2024 at 16:18):

Can we then use pipelines that end with try?

view this post on Zulip Richard Feldman (Sep 18 2024 at 16:19):

I don't think "current block" can work; then if / else branches couldn't escape, e.g.

if foo then
    try File.delete! path

view this post on Zulip Brendan Hansknecht (Sep 18 2024 at 16:20):

Ah yeah, back to the needing to try the control flow

view this post on Zulip Richard Feldman (Sep 18 2024 at 16:20):

yeah exactly

view this post on Zulip Brendan Hansknecht (Sep 18 2024 at 16:28):

If we adding return that also means we are:

  1. Removing the new early return else formatting?
  2. Allowing if without else if it ends with a return?

view this post on Zulip Sam Mohr (Sep 18 2024 at 16:29):

I'd vote yes to both

view this post on Zulip Sam Mohr (Sep 18 2024 at 16:29):

Especially since we have a buggy implementation of the "early return" if-else right now

view this post on Zulip Brendan Hansknecht (Sep 18 2024 at 16:29):

  1. Adding warning for unreachable code after return

view this post on Zulip Sam Mohr (Sep 18 2024 at 16:30):

Yes

view this post on Zulip Brendan Hansknecht (Sep 18 2024 at 16:32):

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.

view this post on Zulip Richard Feldman (Sep 18 2024 at 16:43):

Brendan Hansknecht said:

  1. Adding warning for unreachable code after return

and crash, while we're at it :sweat_smile:

view this post on Zulip Richard Feldman (Sep 18 2024 at 16:44):

presumably the same rules there

view this post on Zulip Sam Mohr (Sep 19 2024 at 20:47):

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

view this post on Zulip Richard Feldman (Sep 19 2024 at 21:07):

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

view this post on Zulip Sam Mohr (Sep 19 2024 at 21:07):

I was really worried about how it would interact with tasks

view this post on Zulip Sam Mohr (Sep 19 2024 at 21:07):

This is another good point

view this post on Zulip Richard Feldman (Sep 19 2024 at 21:08):

currently we're automatically SSA but return wouldn't be

view this post on Zulip Sam Mohr (Sep 19 2024 at 21:08):

I think I can see why

view this post on Zulip Sam Mohr (Sep 19 2024 at 21:51):

Created the early return issue: https://github.com/roc-lang/roc/issues/7104

view this post on Zulip Sam Mohr (Sep 19 2024 at 21:51):

And updated the try issue: https://github.com/roc-lang/roc/issues/7087

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 00:21):

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?

view this post on Zulip Sam Mohr (Sep 20 2024 at 00:22):

I think it's that, a single return makes things easier

view this post on Zulip Sam Mohr (Sep 20 2024 at 00:22):

Though I'm not sure how that will integrate with our FFI continuations

view this post on Zulip Richard Feldman (Sep 20 2024 at 00:46):

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

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 01:38):

llvm has a return instruction I think. So I think it is free from ssa.

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 01:39):

But it is probably best practice to jump to a single exit point if they have the same cleanup.

view this post on Zulip Richard Feldman (Sep 20 2024 at 01:45):

hm I thought it had to be a phi node thing... :thinking:

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 01:59):

Oh yeah...my bad

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 01:59):

Yeah, they all have to merge to a single block and return.

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 02:03):

One of the great niceties of mlir is doing away with phi nodes.

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 02:38):

Phi is only for conditionals that merge into the same control flow later right? but early returns in llvm are fine

view this post on Zulip Richard Feldman (Sep 20 2024 at 02:40):

according to https://mapping-high-level-constructs-to-llvm-ir.readthedocs.io/en/latest/control-structures/ssa-phi.html they aren't allowed

view this post on Zulip Richard Feldman (Sep 20 2024 at 02:40):

(and the solution is SSA into phi node)

view this post on Zulip Richard Feldman (Sep 20 2024 at 02:41):

but I guess dev backend can more directly do an early return?

view this post on Zulip Richard Feldman (Sep 20 2024 at 02:41):

as in like "set return register, adjust stack pointer, jump"

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 02:49):

hmm yeah i mean you can do it this way but you don't need to

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 02:49):

you can just ret the return value and it's fine

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 02:57):

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

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 02:58):

Or well, can also use an alloca and just jump to the final block and return, but the single return in general

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 03:03):

https://godbolt.org/z/be1fnx7W9

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 03:08):

Oh cool. I wonder when that was added. Probably a long time ago. I bet I was looking at long dead forum posts.

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 03:10):

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

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 03:10):

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)

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 03:12):

Just double checked the forum posts, they are mid 2017/18, but I misread them. It is for multiple returns with different return addresses.

view this post on Zulip Ayaz Hafiz (Sep 20 2024 at 03:13):

ah yeah ok

view this post on Zulip Brendan Hansknecht (Sep 20 2024 at 03:14):

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.

view this post on Zulip Oskar Hahn (Sep 20 2024 at 06:49):

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