it occurred to me that once we have Task as a builtin, we could make syntax sugar for the very common |> Task.await operation, if desired.
for example:
str <- File.readUtf8 path |> Task.await
{} <- Stdout.line "str was: \(str)" |> Task.await
File.writeUtf8 otherPath "\(str)!"
this could potentially become:
str <- File.readUtf8! path
{} <- Stdout.line! "str was: \(str)"
File.writeUtf8 otherPath "\(str)!"
it's cool that this is more concise, but it feels awkward to me that you'd use this in every case except the last one (e.g. if you did File.writeUtf8! on the last line, that would desugar to a |> Task.await that shouldn't be there, so you'd get a potentially surprising compiler error - seems like a mistake people would probably make often)
another idea: make an alternate backpassing syntax that's "backpassing plus automatic |> Task.await at the end"
str << File.readUtf8 path
{} << Stdout.line "str was: \(str)"
File.writeUtf8 otherPath "\(str)!"
I like that better than the other idea, although of course << means different things in other languages.
a potential argument for a ! suffix is that it has some similarities to Rust's ? operator (in that you never use it on the last instance), and that doesn't seem to be confusing in practice...although maybe people have different intuitions for the symbols ! and ? (e.g. in Ruby, it's common for method names to end in ! as a convention which means "this mutates its argument(s) in place")
literally using ? would look like this:
str <- File.readUtf8? path
{} <- Stdout.line? "str was: \(str)"
File.writeUtf8 otherPath "\(str)!"
Stdout.line? looks weird to me :laughing:
like someone isn't sure whether they want to actually write a line to stdout or not, still on the fence about it
it's of course always possible that syntax sugar for |> Task.await isn't a good idea, but one thing I notice about this is that it might be nicer for beginners, because they wouldn't stumble over forgetting to write |> Task.await as often
if they just got in the habit of "ok this is the symbol I use when doing one effect after another" as opposed to having something separate to remember
and of course it would make Roc nicer for scripting
Why is the ! or ? after the function name rather than the entire function call with all of its arguments, like it would be in Rust?
I mean I know the answer is "because it looks nicer", just pointing out something I see as a big semantic difference.
You'd need extra parentheses in Roc for that, whereas you have them anyway in Rust for the function call.
I just feel like it's the wrong place for the syntax and as soon as you try it with some complex example it might break
Like if you are doing this with a function returned from another function, or something like that.
could be!
what do you think of the << idea?
It is hard to get rid of features after they are in place. If Task.await is too verbose, one could always import that function from the module and just refere to it as await. That being said, it is a good candidate for sugaring. I think it is reasonable to assume, it would be used with a bunch of backpassing ops. since writing out Task.await would be tedious for long callback chains, which usually get backpassed. (Dont want 40 char length indentation). So if I were to implement a sugar like this, I would personally make a special backpassing operator, like << you proposed. There are a bunch of options.
str << File.readUtf8 path (c++ ptsd, reading into str stream)
str <| File.readUtf8 path (familiar syntax, but ppl will think of "reverse piping")
str <! File.readUtf8 path (could work, but do we want bool operators in it?...)
str <& File.readUtf8 path
str <~ File.readUtf8 path (i like this, but quiet similar looking at a glance to backpassing)
str <€ File.readUtf8 path (because $ gets too much love compared to €.. 😄)
Which suits Roc the best?
It's late here. Good night!
I feel like it may make things harder to understand for beginners. It is less code, but more difficult to follow what is happening.
So Task.await is basically monadic bind right? Just for context, in F# this is solved by "computation expressions", where one specifies which monad one is using. Then one operates in that monad via the computation expression:
let fetchAndDownload url =
async {
let! data = downloadData url
let processedData = processData data
return processedData
}
So here async {...} is saying we want to do the operations using the async monad, "let!" is doing the async call, and passing the value to data. And then you can have several such calls in one computation expression.
https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions
Richard Feldman said:
what do you think of the
<<idea?
I like that much better!
Hmm it's a pity it's specific to Task though. That's a contrast to the back-passing operator, which works for any continuation function, regardless of type.
So it makes me wonder if we can do something more general.
Perhaps like the monadic idea @Johan Lövgren suggested, or like the applicative syntax we discussed in the past. (Though I can't remember what that ended up as!)
I was also thinking about this and I had something like this in mind:
chain
await
str <- File.readUtf8 path
!Stdout.line "str was: \(str)"
!File.writeUtf8 otherPath "\(str)!"
Quite similar to the F# one
2 messages were moved from this topic to #ideas > backpassing arrow syntax by Anton.
I found an old suggestion similar to chain:
sequence await
str <- File.readUtf8 path
{} <- Stdout.line "str was: \(str)"
File.writeUtf8 otherPath "\(str)!"
yeah we've talked about it before...it's definitely an option too!
I think this may be premature. At least that is how I feel in general. I think we really would want to do more advanced task usage. Also, I feel it probably should be a more general solutation that can be used with more than Task.await. Why not with Result.try or other similar functions that could be chained?
The chain and sequence suggestions allow you to supply a specific function to be chained.
Last updated: Jun 16 2026 at 16:19 UTC