it is maybe to much to ask but what is the reasoning behind "syntactic sugar" (task related syntax) I see couple of ways to write the same thing, isn't this to much? It creates confusion and always adds additional effort to decipher code when someone has different style. Maybe focus on 1 max 2 ways to express stuff, just pick the nicer form and drop the others(I ask in general terms not like I expect to invalidate already allowed syntax all the sudden)
Hi @Artur Swiderski, that sounds reasonable :)
Can you give a specific example of a case and all the ways it can be written? That way we can perhaps provide the reason why all the options exist.
await (Stdout.line "Type something press Enter:") \_ ->
await Stdin.line \text ->
Stdout.line "You just entered: \(text)"
_ <- await (Stdout.line "Type something press Enter:")
text <- await Stdin.line
Stdout.line "You just entered: \(text)"
cwd <- Env.cwd |> Task.await
cwdStr = Path.display cwd
_ <- Stdout.line "cwd: \(cwdStr)" |> Task.await
dirEntries <- Dir.list cwd |> Task.await
contentsStr = Str.joinWith (List.map dirEntries Path.display) "\n "
_ <- Stdout.line "Directory contents:\n \(contentsStr)\n" |> Task.await
_ <- Stdout.line "Writing a string to out.txt" |> Task.await
_ <- File.writeUtf8 path "a string!" |> Task.await
similar stuff but slightly different syntax every time
Yep. Backpassing and pipelining definitely give multiple options to write things. That said, they are both very important for readable code in certain cases. Maybe we need to teach them more directly.
Without backpassing, <-
, you get stuck with the problem of nesting. Every lambda leads to another indentation level.
Task.await x (\a ->
Task.await y (\b ->
Task.await z (\c ->
...
)
)
)
Becomes
a <- Task.await x
b <- Task.await y
c <- Task.await z
Of course you still want regular lambda syntax for a few reason. (Function definition, one offs, lambdas that don't take up the rest of the scope).
_ <- File.writeUtf8 path "a string!" |> Task.await
intuitively I would expect this way
_ -> File.writeUtf8 path "a string!" |> Task.await
because when
a <- Task.await x
b <- Task.await y
works how it works,
_ <- File.writeUtf8 path "a string!" |> Task.await becomes confusing
Pipelining, |>
, on the other hand has a more limited use case where it is quite important. It is extremely useful for the readability of data transformation pipelines.
List.map (List.keepIf (List.reverse (List.walk data 0 Num.sub)) (\x -> x > 7)) (\x -> x - 2)
or
data2 = List.map data \x -> x - 2
data3 = List.keepIf data2 \x -> x > 7
data4 = List.reverse data3
result = List.walk data4 0 Num.sub
Becomes
data
|> List.map \x -> x - 2
|> List.keepIf \x -> x > 7
|> List.reverse
|> List.walk 0 Num.sub
The syntax that you find confusing is when pipelining is mixed with backpassing. This syntax is less necessary, but is allowed due to mixing the two above described syntaxes.
_ <- File.writeUtf8 path "a string!" |> Task.await
is a way to move the relatively unimportant and repetitive Task.await
to the end of the line. That way you can just read the operation that is happening File.writeUtf8
. So it is a minor enhancement to readability if you know how it desugars, but I totally get that it is a strange syntax on its own.
I guess you would find it more readable with parenthesis:
_ <- (File.writeUtf8 path "a string!" |> Task.await)
Looking specifically at desugaring it, I see two ways it could be done (I am not 100% sure which ordering happens in the compiler).
way 1 (pipeline desugars before backpassing):
_ <- File.writeUtf8 path "a string!" |> Task.await
to
_ <- Task.await (File.writeUtf8 path "a string!")
to
Task.await (File.writeUtf8 path "a string!") \_ -> ...
way 2 (backpassing desugars before pipelining):
_ <- File.writeUtf8 path "a string!" |> Task.await
to
File.writeUtf8 path "a string!" |> Task.await \_ -> ...
to
Task.await (File.writeUtf8 path "a string!") \_ -> ...
Hopefully that at least helps to explain what is happening some and the reason why both of the syntaxes exist. I totally understand that how they combine may be super strange especially when first learning
interestingly, "fewer ways to do the same thing" is an argument I hadn't previously considered for why with
might be a good idea:
with Task.await
cwd <- Env.cwd
cwdStr = Path.display cwd
_ <- Stdout.line "cwd: \(cwdStr)"
dirEntries <- Dir.list cwd
contentsStr = Str.joinWith (List.map dirEntries Path.display) "\n "
_ <- Stdout.line "Directory contents:\n \(contentsStr)\n"
_ <- Stdout.line "Writing a string to out.txt"
_ <- File.writeUtf8 path "a string!"
How would that make fewer ways to do the same thing? That just adds another way.
oh I'm thinking add that and remove backpassing - I don't think we need both
Oh, but then you run into the issue of mixed functions. List.mapTry
, Result.try
, List.map
, etc. All of them benefit from backpassing. with
limits only one function to benefiting from backpassing. You lose all of the adhoc uses.
Task and result might be avoided together, but lists and tasks would be used together. So that could still likely benefit from backpassing.
hm, what would an example of that mixed-use look like? :thinking:
I don't know of any where the types actually work out, but I could be missing something!
I guess we did discuss this some before and it is more one off use rather than mixed use. That or nested use. I'll try to type up an example later, but in general, i might nest the scope, use Result.try with backpassing within the nested scope and then continue on from that. This would require double nesting for a use case like that and would be very verbose.
Oh, what about something like this:
resultHttp =
row <- Result.try rowResultHttp
todoResult = loadRowToTodo row baseUrl
todoResultHttp = mapErrToHttp todoResult headers 500
todo <- Result.map todoResultHttp
todoStr = writeTodo "" todo
Response {status: 200, body: todoStr, headers}
Uses Result.try
and Result.map
(Theoretically this would also apply to Task.await
and Task.map
). I don't think it would work with with
. Or if it did, it would be something like:
resultHttp =
with Result.try
row <- rowResultHttp
todoResult = loadRowToTodo row baseUrl
todoResultHttp = mapErrToHttp todoResult headers 500
with Result.map
todo <- todoResultHttp
# TODO replace this with json encoding
todoStr = writeTodo "" todo
Response {status: 200, body: todoStr, headers}
I think in general it makes any one off or nested uses quite verbose. So I think regular backpassing will still have value.
For context, here is a full chunk of code with a mix of backpassing functions (and one Result.map
that couldn't use backpassing due to the type problem you mentioned):
updateResult <- dBExecute "UPDATE todos SET title = ?1, completed = ?2 WHERE id = ?2" [Text title, Boolean completed, Int id] |> Task.await
updateResultHttp = mapErrToHttp updateResult headers 500
(Result.map updateResultHttp \{rowsAffected} ->
when rowsAffected is
1 ->
# Fetch and return the todo.
rowResult <- dBFetchOne "SELECT id, title, completed, item_order FROM todos WHERE id = ?1" [Int id] |> Task.await
rowResultHttp =
when rowResult is
Ok v -> Ok v
Err NotFound -> Err (Response {status: 404, body: "", headers})
Err _ -> Err (Response {status: 500, body: "", headers})
resultHttp =
row <- Result.try rowResultHttp
todoResult = loadRowToTodo row baseUrl
todoResultHttp = mapErrToHttp todoResult headers 500
todo <- Result.map todoResultHttp
todoStr = writeTodo "" todo
Response {status: 200, body: todoStr, headers}
mergeResult resultHttp |> always
_ ->
Response {status: 500, body: "", headers}
) |> mergeResult |> Task.always
would this work?
with Task.await
updateResult <- dBExecute "UPDATE todos SET title = ?1, completed = ?2 WHERE id = ?2" [Text title, Boolean completed, Int id]
when mapErrToHttp updateResult headers 500 is
Ok { rowsAffected: 1 } -> with Task.await
# Fetch and return the todo.
rowResult <- dBFetchOne "SELECT id, title, completed, item_order FROM todos WHERE id = ?1" [Int id]
when rowResult is
Ok row ->
row
|> loadRowToTodo baseUrl
|> mapErrToHttp headers 500
|> Result.map \todo ->
Response {status: 200, body: writeTodo "" todo, headers}
|> mergeResult
|> Task.always
Err NotFound -> Task.fail (Response {status: 404, body: "", headers})
Err _ -> Task.fail (Response {status: 500, body: "", headers})
_ ->
Task.fail (Response {status: 500, body: "", headers})
although in fairness, I like the backpassing version of :point_up: better :big_smile:
updateResult <- dBExecute "UPDATE todos SET title = ?1, completed = ?2 WHERE id = ?2" [Text title, Boolean completed, Int id] |> Task.await
when mapErrToHttp updateResult headers 500 is
Ok { rowsAffected: 1 } ->
# Fetch and return the todo.
rowResult <- dBFetchOne "SELECT id, title, completed, item_order FROM todos WHERE id = ?1" [Int id] |> Task.await
when rowResult is
Ok row ->
row
|> loadRowToTodo baseUrl
|> mapErrToHttp headers 500
|> Result.map \todo ->
Response {status: 200, body: writeTodo "" todo, headers}
|> mergeResult
|> Task.always
Err NotFound -> Task.fail (Response {status: 404, body: "", headers})
Err _ -> Task.fail (Response {status: 500, body: "", headers})
_ ->
Task.fail (Response {status: 500, body: "", headers})
Yeah, i think other solutions definitely are possible, but i think supporting the backpassing syntax adds a lot of refreshing flexibility.
Also, i am am not sure how often Task.await
and Task.map
will want to be used together, but i suspect fully removing backpassing would end up with a number of limitations.
Aside, that hidden with Task.await
at the end of the line feels worse than |> Task.await
to me. Bother are trying to hide the Task.await
for readability, but the with
is super hidden and hard to find without the clear indentation.
another thing I hadn't considered is that with
inside another with
is a form of shadowing
(because <-
is taking on a new meaning)
but without allowing that, nested code like this would be less nice
so that's another point for backpassing
maybe I need to give it some more time, but whenever I see backpassing used with map
I don't like how it reads :sweat_smile:
Thx for explanation. As a side comment I would say that construction "with Task.await" I find even more confusing. Whenever there is one liner impacting all below, I am struggling especially in language where there is no much usage of delimiters like {} , (). I like more _ <- because it at least act on its immediate neighborhood. With this "with Task.await" I am lost because I lose touch, what effects what. Is this another legal way to express the same concept?
Artur Swiderski said:
sometimes more verbose isn't necessarily worser because I usually do a lot of copy pasting anyway so I do not save time with shorter enigmatic syntax. But it causes a lot of burnout when I am forced to decipher like 10 lines at once instead of one two three lines.
obviously things like :
[list]
|> dosomething
|> dosomething
|> dosomething
|> dosomething
|> dosomethingare ok
but this :with Task.await
cwd <- Env.cwd
cwdStr = Path.display cwd_ <- Stdout.line "cwd: \(cwdStr)" dirEntries <- Dir.list cwd contentsStr = Str.joinWith (List.map dirEntries Path.display) "\n " _ <- Stdout.line "Directory contents:\n \(contentsStr)\n" _ <- Stdout.line "Writing a string to out.txt" _ <- File.writeUtf8 path "a string!"
: O and my jaw drops
below is weird but at least I can analyze it basically line by line:
_ <- await (Stdout.line "Type something press Enter:")
text <- await Stdin.line
Stdout.line "You just entered: \(text)"
and being able to analyze program line by line is huge boost for my productivity. In general it boils down to context if I do not have to keep track of contex I am currently in good for me. This is strong side of languages like "C" with its all flows, I can read each line separately because context is always simple. In provided example "with Task.await" I expect that all below lines will behave in some weird way I have to keep track of those ways. For examle in "C++" when program is heavily templated, one code snippet may mean nothing without knowing context of at least some parameters, from template list and that's horrible, looks cool but is horrible nevertheless. So I like when every line tells its own story I read code like a book I don't have to think so much I don't have to jump from place to place distract myself
I am rising all of that because I am more like bug fixer type than application creator(at least by trade) so I have to skim through a lot of code daily (sometimes I am not so much familiar with). So I just know what works for me best. So many times I see code with single conceptual context scatter all over the places, I have to jump here and there to gather it all back to one piece in my head. Those riddles looks sometimes so professional but they are killing me. When I have opportunity to read code like a book line by line is so good ( but rare nowadays ). By trade I do C++ and it's crazy how people are abusing syntax of this language just insane at times
Thanks @Artur Swiderski, excellent feedback!
look at lua language, it has relatively simple straightforward syntax (although it is too verbose at times ). Right away I am able to work with this language because of that. In general, I am looking for language with couple of traits (and roc potentially has many of them) among those syntax is not the most important consideration but still major factor. As to lua I don't like other design choices they made but regarding syntax, I will never complain.
For roc I have hope to replace python in my usual use cases. Sometimes I write simple tool to analyze some data in files in very specific way. Functional languages are good with parsing, so "roc" should excel. In python because language is in such common use (a lot of examples and references online) I can complete task in couple of minutes but I find experience not satisfying and prone to minor mistakes. I hope I will be able to complete the same in roc, in similar time but in somehow less "ad hoc" manner. Since those are very small programs for personal use I can use anything in terms of technology. In this use case, syntax is much less important but still it is good to see something which fits my general world view
I was also interested in roc because of my dissatisfaction with python.
Dynamic languages like python and lua are easy to get started with. It's noticeably harder to keep things simple with a pure functional language but we'll keep trying :)
Last updated: Jul 06 2025 at 12:14 UTC