Stream: beginners

Topic: syntactic sugar


view this post on Zulip Artur Swiderski (Nov 26 2022 at 17:46):

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)

view this post on Zulip Anton (Nov 26 2022 at 18:24):

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.

view this post on Zulip Artur Swiderski (Nov 26 2022 at 18:40):



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

view this post on Zulip Artur Swiderski (Nov 26 2022 at 18:40):

similar stuff but slightly different syntax every time

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 18:44):

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.

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 18:48):

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

view this post on Zulip Artur Swiderski (Nov 26 2022 at 18:55):

_ <- 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

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 18:57):

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

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 19:01):

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.

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 19:02):

I guess you would find it more readable with parenthesis:
_ <- (File.writeUtf8 path "a string!" |> Task.await)

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 19:05):

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!") \_ -> ...

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 19:06):

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

view this post on Zulip Richard Feldman (Nov 26 2022 at 20:17):

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

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 20:20):

How would that make fewer ways to do the same thing? That just adds another way.

view this post on Zulip Richard Feldman (Nov 26 2022 at 21:00):

oh I'm thinking add that and remove backpassing - I don't think we need both

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 21:16):

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.

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 21:18):

Task and result might be avoided together, but lists and tasks would be used together. So that could still likely benefit from backpassing.

view this post on Zulip Richard Feldman (Nov 26 2022 at 21:19):

hm, what would an example of that mixed-use look like? :thinking:

view this post on Zulip Richard Feldman (Nov 26 2022 at 21:20):

I don't know of any where the types actually work out, but I could be missing something!

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 23:23):

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.

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 23:32):

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}

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 23:36):

I think in general it makes any one off or nested uses quite verbose. So I think regular backpassing will still have value.

view this post on Zulip Brendan Hansknecht (Nov 26 2022 at 23:38):

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

view this post on Zulip Richard Feldman (Nov 27 2022 at 01:08):

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

view this post on Zulip Richard Feldman (Nov 27 2022 at 01:14):

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

view this post on Zulip Brendan Hansknecht (Nov 27 2022 at 01:47):

Yeah, i think other solutions definitely are possible, but i think supporting the backpassing syntax adds a lot of refreshing flexibility.

view this post on Zulip Brendan Hansknecht (Nov 27 2022 at 01:50):

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.

view this post on Zulip Richard Feldman (Nov 27 2022 at 02:11):

another thing I hadn't considered is that with inside another with is a form of shadowing

view this post on Zulip Richard Feldman (Nov 27 2022 at 02:11):

(because <- is taking on a new meaning)

view this post on Zulip Richard Feldman (Nov 27 2022 at 02:11):

but without allowing that, nested code like this would be less nice

view this post on Zulip Richard Feldman (Nov 27 2022 at 02:11):

so that's another point for backpassing

view this post on Zulip Richard Feldman (Nov 27 2022 at 02:12):

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:

view this post on Zulip Artur Swiderski (Nov 27 2022 at 09:28):

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?

view this post on Zulip Artur Swiderski (Nov 27 2022 at 09:36):

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
|> dosomething

are 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

view this post on Zulip Artur Swiderski (Nov 27 2022 at 09:56):

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

view this post on Zulip Artur Swiderski (Nov 27 2022 at 10:10):

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

view this post on Zulip Anton (Nov 27 2022 at 11:30):

Thanks @Artur Swiderski, excellent feedback!

view this post on Zulip Artur Swiderski (Nov 27 2022 at 12:48):

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.

view this post on Zulip Artur Swiderski (Nov 27 2022 at 13:01):

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

view this post on Zulip Anton (Nov 27 2022 at 15:19):

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