I've been thinking about how best to help newcomers with understanding Task
and effects. Specifically how with roc we are building up a data structure which describes what to do, and then passing that off to the platform to actually do the thing... so chaining tasks doesn't actually do anything until they are executed.
It feels to me like this is the most important concept to work effectively with roc.
I like a good graph or illustration.
I've been thinking if it would be possible to take a program written for e.g. basic cli, swap out the platform URL, and it generates some kind of graph which shows the task sequencing.
Has anyone ever seen anything like that?
Maybe this platform just boots up a webserver and displays on a webpage?
I imagine having like an VS Code extension open that displays a window next to my code as I develop it, and I can see in real time what the tasks are doing.
hm, I think showing a graph would require language-level integration
because as soon as you hit an await
, the platform can't possibly know what comes next until the callback function has actually executed
the compiler can look at the source code for the callback and analyze which code paths can potentially result in which other tasks, and draw a graph from there
but the platform can't see the source code, so all it knows is the 1 path that actually got taken, not what the alternatives were
Richard Feldman said:
hm, I think showing a graph would require language-level integration
Like a language server maybe?
could be!
Glad to hear it's possible to build. Would that be useful though? Has anyone ever seen anything like that used elsewhere?
it might be useful, although I'm not sure how much of a problem this is currently :big_smile:
it doesn't seem to be something I notice a lot of questions about!
I think async is so common nowadays that it isn't too important
That said, the task mutation is a bit more than simple async in some cases
Specifically how with roc we are building up a data structure which describes what to do, and then passing that off to the platform to actually do the thing... so chaining tasks doesn't actually do anything until they are executed.
Can you add that to the tutorial @Luke Boswell? I also think that is worth mentioning.
Generally it's referred to as "cold tasks" it'd be good to mention that c#, JavaScript, and maybe java(I'm not quite sure) all have "hot tasks"
Is async in rust with Tokio cold tasks? Do you know? I think we theoretically will be compiling to basically the same thing when we switch to effect interpreters.
Something that would be really cool is some kind of task tracer:
Ocaml's effect system has a few, one being this:
https://github.com/ocaml-multicore/eio-trace
It would be pretty cool if roc could generate similar traces that show a kind of flamegraph all the effects that were run.
@Brendan Hansknecht
My brain is telling me yes, but I'm not totally sure.
If you make an Async block it doesn't do anything until you run it on a thread right? And you can do joins, and awaits and everything inside without actually starting the task, you are just bringing up a big future data type, right?
Whereas in JS for example, you can only have a promise when a task is actually running.
I found this:
https://users.rust-lang.org/t/similarities-between-c-async-await-and-tasks-and-rust-async-await-and-futures/52771/2
They say the same thing, rust has cold Async.
I'm trying to research "hot" and "cold" tasks, but haven't much. Are these similar to hot and cold observables?
I'm not sure the observer pattern really fits in this context
Found something that looks right;
Understanding tasks and task generators
In the paper about asynchronous programming model in F# [1] (written by Don Syme, myself and Dmitry Lomov), we mentioned three possible options that can be used for implementing the asynchronous programming model:
- Hot tasks (used by C#) - in this case, the asynchronous code block returns a task that has been already started and will eventually produce a value.
- Cold tasks - in this model, the code block also returns a task that will eventually produce a value, but it doesn't start the task. The caller is responsible for starting it and may decide not to start it at all.
- Task generators (used by F#) - in this case, the code block returns an object that will generate and start a task when it is provided with a continuation to be called when the operation completes.
Yeah, maybe it's a more common term in the f# world, but I have heard it mentioned elsewhere.
I know somewhere I've read a list of examples where what you're used to in hot tasks won't happen with cold tasks
.
The simplest one would be:
a=
doThing= Stdout.printLine "hi"
100
In a hot task model, you would expect this to print "hi", In a cold task model like rock this does nothing.
I'm very used to it now, but I do remember when I was first learning f sharp I had a bunch of times where I thought something should have started and it just didn't run and it was very confusing
This is really good context or background to explaining tasks in the tutorial as Anton mentioned. It will be good to reference familiar concepts from other languages.
maybe...I have to admit, I have a bit of a concern that we're talking about solving a problem that doesn't have any symptoms :sweat_smile:
is there a reason we suspect beginners are currently having trouble understanding how tasks work in Roc?
I actually try to minimize referring to features of other languages, for a few reasons:
I think the most useful ways to refer to things in other languages are for really broad things that appear in multiple languages, e.g. "tasks are kind of like Promises, but they don't do anything right when you create them" or "platforms are kind of like frameworks but they are in charge of more things, such as memory management and all I/O operations"
Richard Feldman said:
maybe...I have to admit, I have a bit of a concern that we're talking about solving a problem that doesn't have any symptoms
is there a reason we suspect beginners are currently having trouble understanding how tasks work in Roc?
I should have provided more background to explain why I think it would be beneficial to explore this idea. I'm not trying to highlight a specific issue; I wanted to explore if there is an opportunity to improve and help people build a mental model.
Getting comfortable putting the lego's together await
attempt
try
loop
map
fromResult
ok
err
has taken me a while. The first time I wrote two Stdout.line
to print to the screen, I was confused about how/why to chain the Tasks together.
Understanding Tasks is fundemental to using roc, and I had to build up a mental model and gradually refine what I understand a Task is and how it works.
My thought was how can I help others to get to a working mental model without needing to spend as much time as I have.
I think the design of Tasks is great - they are really good to work with - so I don't think there is a problem with the design.
I'll sit on these ideas for now and come back to this another time.
I do think that !
, better compiler errors, and some bug fixes around requiring Task.loop
will greatly reduce the overhead here. So I think as time passes, there will be less and less to explain.
I actually mostly agree with you @Richard Feldman. I would say in this case people coming from almost any mainstream language would only have experience with hot tasks, and therefore have incorrect assumptions. I do think that it's worth highlighting, that there is a fundamental difference in how they execute. I can obviously only speak to my one experience learning this (if only we could try learning things again) but I would have benefited from being told up front "your assumptions about what a task is doing are likely wrong"
that all makes sense! :100:
Ah, this conversation is making me feel better already: I'm having trouble understanding how tasks work in Roc. In my attempts I get the wrong results most of the time, which shows that my mental model of Tasks (and Results and their relationship?) is not good at all.
So, for me at least, it has been a pain point. A fun one, but still :smile:
As a Roc/Elm beginner (that I still am), my initial encounter with Tasks
was not straightforward, so I'll try to share my perspective, in case it might be of help.
Initially, the crude mental model that I leaned towards was a parallel to how async-await works in languages like Python and JavaScript in terms of being able to chain multiple async functions together, and then do error-handling on top of that correspondingly. In Roc, my conceptual (yet quite far from perfect) understanding is that tasks are currently only platform-specific concepts. Basically, by defining a task (or a chain of tasks), one indicates to the platform what the tasks constitutes of, ahead of execution time. The execution itself happens at a later point in time that's relevant from the perspective of the platform itself, which is after the Roc code has handed over the instructions; and the platform itself implements the lower-level details, pertaining to the how of execution, including scheduling, result gathering etc.
I happened to listen to what I believe is quite a relevant Software Unscripted podcast episode yesterday, and I hope the admins wouldn't mind my posting an excerpt from the transcript here (the excerpt is from page 5/20). I believe this is a much better summary than what my attempt could've made justice to tasks, but I decided to offer my beginner's perspective nevertheless, as a juxtaposition (and also, in case, it'd be helpful to the thread if more experienced Roc/Elm programmers would have time to indicate inaccuracies in my particular perspective):
Richard Feldman:
Whereas an Elm, I might do the same thing with a task. Like I have a function
that returns a task and just like promises, tasks can be chained together. They
can represent like, I want to do an HTP request and then I want to do this other
thing, yada, yada. But the difference is that in the Elm function, when I return it,
yeah, it's going to return this task, and that task sort of represents the HTTP
request that I want to have done. The difference is just that if I call the
JavaScript function a hundred times, it's going to fire off 100 HTTP requests.
If I call the Elm function 100 times, it's still just going to return this value that
describes, "Hey, I want an HTTP request to happen here." It's not going to fire
off 100 times. It's still just going to be, well, I got this task back. So the
difference between task and promise is really just a question of side effects
versus managed effects. Side effects being right when you make it, it does the
effect. Managed effect being, this is just a description of the effect that I want
the run time to do in the future.
...
And the follow-up to that is that I think people might have an idea in their
head that doing pure functional programming is going to be radically different in
terms of what it feels like day-to-day than something like imperative
programming when it comes to effects. But actually it's really not that different.
You see a lot of things that instead of returning promise, they return task, but
you chain them together in basically the same kinds of ways. The only difference
is when they actually get executed by the run time versus immediately.
Thank you @Hristo for sharing the link to that episode; it was helpful.
Going back to what I said about having trouble understanding Tasks: as I make progress, I've been reflecting on the errors I made before, and the beliefs that led to them.
Thinking imperatively
My head kept reading patterns such as result <- (someFunction "arg")
as "ah! we're assigning the result of the task to result
, so we should have a string there, right?".
It took some time to understand that I'm writing a recipe to handle the result of something that will run in a parallel universe (the platform). What comes out of that portal is not a string: it's _something_ that could be a string, but all possible cases must be considered.
Syntax
The backpassing and pipe operators in particular were confusing to me. In my first attempts, I did all sorts of weird stuff that resulted in wrong arguments passed, type mismatches and panics. Writing the same fragments in sweet / sugar-free versions helped a lot.
I think I would have benefited from having more examples in both sugary and desugared versions, considering that the docs are written with syntax sugar.
Overconfidence
Although I was familiar with the concepts of functional programming, I didn't have hands-on experience —and yet I expected to understand things just from glancing at examples. I was wrong.
Thank you for sharing @Noel R. Have you seen the Chaining Syntax design proposal? You may find that interesting, and I would be keen to know what you think.
I've been working on implementing it recently and I think it will be a nice improvement to not use backpassing and have a more direct statement loooking syntax for Tasks.
I hadn't! I really like that proposal. Reading it, I realized that the visual noise added by the {} <-
fragments is significant. The eyes cannot simply scan vertically to understand the outline of the code, so additional effort must be made to first decide where the line starts, and then decode its meaning. For a single line it's trivial but when scanning code my eyes really appreciate the clean edge.
A concern I'd have with using the assignment operator =
for two purposes (backpassing and assignment) is that, if I understand correctly, it would be impossible for someoneone reading code to desugar just from the text on screen without fully understanding the code —this could be an issue for beginners like myself.
So, I'm wondering if we can get the best of both worlds by keeping the backpassing operator (not sure if operator is the right word :thinking: ), while applying the rest of the proposal.
For example:
content <- File.readUtf8! path
instead of
content = File.readUtf8! path
I think a main goal of !
is accepting that generally speaking, users don't really need to think about the await and it's underlying closure.
They just need to know that !
means async function.
Backpassing is a fundamentally more powerful syntax, while !
is simpler and hides more to avoid confusion and details that many users don't need.
As for desugaring. <-
vs =
should make no difference. The desugaring happens due to the !
. So if you scan for those, you can desugar anything.
Luke Boswell said:
Thank you for sharing Noel R. Have you seen the Chaining Syntax design proposal? You may find that interesting, and I would be keen to know what you think.
I've been working on implementing it recently and I think it will be a nice improvement to not use backpassing and have a more direct statement loooking syntax for Tasks.
That's awesome!
Fits perfectly into "let me write core of the $thing in pure functional way, and glue it synchronously using similar syntax".
I'm also for retiring <- syntax.
The only change I will suggest: instead of taking ?
character for something that typical code will not do, consider changing it so you have (! SomeTask.await)
, aka the same !
, but with parens and argument.
Can you explain your suggestion more? Maybe give an example with args?
(in my suggestion)main = Stdout.line! "Hello, World"
could be rewritten as main = Stdout.line (! Task.await) "Hello, World"
. Parenthesis makes it more obvious, but could not be removed (same arguments that go against curring).
Looks bloatier that ? Task.await
- just a bit, but we don't spend the valuable ?
character that could be used for future extensions.
I kind of like having the more concise syntax that is in the proposal. Here is an example I've been looking at earlier.
# today's syntax w/o backpassing
main =
Task.await (Stdout.line "Ahoy") \{} ->
Task.await (Stdout.line "there") \{} ->
Task.ok {}
# with chaining syntax
main =
Stdout.line! "Ahoy"
Stdout.line! "There"
Task.ok {}
Both of these print out Ahoy There
to stdio, but I think the second is much easier to follow at a glance.
The former will still be valid as the !
is just syntax sugar and unwrap to the exact same code.
I think a main goal of
!
is accepting that generally speaking, users don't really need to think about the await and it's underlying closure.
I'm not sure if I would actually want the existence of the await and underlying closure to be hidden: it seems to me that async code and task handling are fundamental to the language.
As a learner, I'd rather keep that in mind, but having a syntax alternative that's nicer to write and read is great. I'm not sure I understand the power of backpassing so I can't comment on that.
This example can be found in the proposal:
main =
Stdout.line! "one"
Stdout.line! "two"
Would that be valid? From Luke's example above, and considering that it's just syntax sugar, I think it wouldn't: the last line should not have the !
or it should be followed by something that produces a task.
If that's correct, then it's one of those cases that make me think that we can't hide the existence of tasks and async behavior from users. The readability can improve for sure and, in that line, I really like this proposal :ok:
I'm pretty new to Roc and have zero experience in language design, so take all of this as newbie opinion.
That is allowed. It is a special exception to the sugar. A !
on the final task of a function is just ignored. It enables users to write consistent code.
Also, by hide, I mostly mean we can teach the simple thing first and if that is enough for users, they can just play with the language. If not, we can dive in and explain the sugar to give them more understanding.
Brendan Hansknecht said:
That is allowed. It is a special exception to the sugar. A
!
on the final task of a function is just ignored. It enables users to write consistent code.
as a minor note, I'd like to try not allowing this at first and seeing how it goes (maybe with a nice compiler error if you include it but shouldn't; that's a very easy scenario to detect)
a downside of optionally allowing it that I hadn't thought of originally is that there's no way for roc format
to enforce the convention that you should always have it
because roc format
doesn't know the types of the program, so it can't know what is and isn't a task
and it certainly can't go around adding !
to every function call at the end of defs, because that would not work out most of the time :sweat_smile:
so I think we should do the experiment of not allowing the optional !
at the end and see how it goes
because it's trivial to have a consistent style with that design
actually we could have it be a warning, come to think of it
(with a helpful message)
I get the reasons, but it does lose a solid chunk of the benefit of !
in my opinion. Will make people question it more and pull back the covers sooners.
hm that's a good point
I guess a related question is: how much of a concern is it if people use it inconsistently? :thinking:
like how annoyed would I be if a code base formatted using roc format
sometimes did and sometimes didn't use the trailing !
hm, maybe the better experiment to run is whether roc format
not enforcing it is actually a problem in practice
Also, I guess we have 3 possible styles:
main =
Stdout.line! "A"
Stdout.line "B"
main =
Stdout.line! "A"
Stdout.line! "B"
main =
Stdout.line! "A"
Stdout.line! "B"
Task.ok {}
First and last valid for sure. Middle depends on the optional last !
yeah whenever I write out nontrivial examples I prefer the middle style
it's kinda like how in Rust I always put ;
at the end of things that return ()
even when it's at the end of the expression and I don't need to
Same. I think I actually like the first example the least. Even though I know it isn't inconsistent, it feels inconsistent.
and I guess actually that's an interesting precedent; rustfmt
doesn't enforce trailing semicolon consistency (probably because that would also require type information) and it seems totally fine in practice
as a minor note, I'd like to try not allowing this at first and seeing how it goes (maybe with a nice compiler error if you include it but shouldn't; that's a very easy scenario to detect)
I like not having the exception since having an exception adds a conditional meaning to !
.
From the three styles, I'd lean towards 3 since it's more explicit (and 1 looks weird...)
Brendan Hansknecht said:
I get the reasons, but it does lose a solid chunk of the benefit of
!
in my opinion. Will make people question it more and pull back the covers sooners.
This could be a good thing though. It's convenient for the common case, but still encourages people to understand "why do I need to add the Task.ok {}?"?
I've had a couple of cracks at supporting the optional last place thing. It's probably doable, but it will require some changes in the parser. I think the issue is where does an expression end, at the moment an expression can continue across a newline at the same indent, but that means it's hard to detect a final expression like this.
So the PR I'm working on doesn't have that feature yet.
Personally, I have no issues with either of these syntaxes:
main =
Stdout.line! "A"
Stdout.line! "B"
main =
Stdout.line! "A"
Stdout.line! "B"
Task.ok {}
This is the only syntax that I think will be inconsistent/confusing:
main =
Stdout.line! "A"
Stdout.line "B"
For completeness, could somebody provide an example of how (a relatively non-trivial chain of) error handling might look like (in case there'd be distinctive differences) with the proposed alternative syntax?
Thanks!
I don't think theTask.ok {}
design is nice in longer examples :big_smile:
it looks fine here but not in a big chunk of code that's doing a bunch of I/O
I agree, I think it will be nicer. Just wanted to add that it may not be so bad.
@Hristo
main = run |> Task.onErr \err -> Stdout.line "ERROR: $(Inspect.toStr err)"
# task that bundles errors
# into a single tag union
run : Task {} _
run =
# a couple of statements
Stdout.line! "Ahoy"
"There" |> Stdout.line!
# check jq is available
Cmd.new "jq"
|> Cmd.arg "--version"
|> Cmd.status
|> Task.mapErr! UnableToCheckJQVersion
8 messages were moved from this topic to #beginners > Error handling with tasks by Brendan Hansknecht.
To be complete, here are the three versions of a more complex example from the design proposal. I just added in error handling. I internationally made the error handling more explicit to show the apis. Also, I made one function where there is no way to end all branches with bang.
Unnecessary ! at end
Minimal ! possible
Explicit result at the end
If anything, this makes me realize that consistantly sticking to any one approach on all functions is unlikely to work well.
I would use the unnecessary !
for storeEmail
and would avoid it in withRetries
.
my perference
For completeness:
current syntax
This reminds me of the discussion of "Ok
-wrapping" for Rust's try {}
blocks feature. With Ok
-wrapping, the final expression in a try
block is automatically wrapped in Ok()
. (And if it's omitted, then it defaults to Ok(())
, the equivalent of Roc's Task.ok {}
.) If Roc were to adopt some kind of Task.ok
-wrapping, I think it would force a more consistent style where you always use !
for trailing function calls.
With ok-wrapping, Brendan's example might look like this
withRetries : Task ok err, U64 -> Task ok err
withRetries = \task, attempts ->
res = Task.toResult! task
when res is
Ok x -> x
Err e ->
if attempts == 0 then
Task.err! e
else
withRetries! task (attempts - 1)
Interesting. So it makes the !
required on all paths even the error path.
Ah, though it has Ok x -> x
as well. Cause x
is of the ok result type and will be wrapped automatically.
Exactly. And if Task.err!
reads weirdly, you might create an alias like throw!
or bail!
to make it read more like an imperative command
Luke Boswell said:
Thank you for sharing Noel R. Have you seen the Chaining Syntax design proposal? You may find that interesting, and I would be keen to know what you think.
This is so impressive! I'd like to add that F# has gone similar way, but with one distinct difference: the meaning of the !
syntax (it is kind of similar to the new syntax design proposal above) can vary depending on the computation expressions and there are some included in the standard library and also by 3rd parties.
Example:
let doThingsAsync url =
async { // <-------- this is the computation expression defining the meaning of ! within
let! data = getDataAsync url
do! sendDataAsync data
}
So, the !
is for chaining async tasks, but in other environment they do other things, basically it all revolves around chaining monads and/or applicatives.
I am not suggesting the Roc should have something similar, but F# is not very popular, but it has lots of interesting (i.e. inspiring) stuff, sometimes I can't help but feel obliged to share it to wider audience :)
Computation expressions in F# provide a convenient syntax for writing computations that can be sequenced and combined using control flow constructs and bindings. Depending on the kind of computation expression, they can be thought of as a way to express monads, monoids, monad transformers, and applicative functors. However, unlike other languages (such as do-notation in Haskell), they are not tied to a single abstraction, and do not rely on macros or other forms of metaprogramming to accomplish a convenient and context-sensitive syntax.
additional reason for allowing (and preferring) a trailing !
is usage with git, when you add a new action as a new last, it's best when you don't touch previous line (think git blame).
sometimes it would be beneficial to remove the trailing !
, which would mean 'no more code expected after this line'
Such convention works fine for C enum
s, you always add a ,
at the end, but don't if it's something intended to be the last one (size/count/max).
@witoldsz I think the original discussion and design was actually more similar to the F# version.
Something like
with Task.await
Stdout.line!
...
I think in the end the preference leaned more towards it probably only being needed for a few types with Roc's current design (Task and Result for example). So it would be nicer to make it less noisy and just work. There was also discussion of making it just work with any andThen
style function that is specified for a type. But that would only be added with significant demand due to the extra complexity.
For now it is just being added as special magic for Task.
Yes, I do find it attractive that the current proposal focuses on !
as a specialized construct just for Task.await
, so the entry point is so low that pure-fp Roc stands up to an even fight with folks like Ruby or Python in scripting capabilities.
Now, I do notice the with ...
syntax is more like computation expressions of F# but as far as I can see the Roc proposition is not as powerful, especially when it comes to applicatives. Applicatives are superb for example when you want to validate a form and do not want to stop on a single error, but continue with all checks and collect errors. In F# you can do it like this:
validate {
let! country = Check.optional (asString >=> isCountryCode) $"{field}.country" (decode json "country")
and! city = Check.optional (asString >=> Check.String.betweenLen 2 100) $"{field}.city" (decode json "city")
and! postCode = Check.optional (asString >=> isPostCode) $"{field}.postCode" (decode json "postCode")
and! streetAndNumber =
Check.optional
(asString >=> Check.String.betweenLen 1 250)
$"{field}.streetAndNumber"
(decode json "streetAndNumber")
return
{ Country = country
City = city
PostCode = postCode
StreetAndNumber = streetAndNumber }
}
Notice: validate
CE comes from 3rd party library. The result is either a record or a collection of errors.
That is very similar to our record builder syntax, which I think was built for this purpose. Recently there was an example of it in #show and tell > Weaver: record builder CLI parsing library
Wait, clarifying question: is record builder syntax it's own thing, separate from backpassing?
Yes
@Jasper Woudenberg we ended up changing the syntax slightly, but here is the original design doc: https://docs.google.com/document/d/1Jo9nZCekkoF6SaDcRqPqoPcgPaAAvlNZC7v3kgVQ3Tc/edit?usp=sharing
I'm really excited to see the new language constructs introduced in the latests version. looks like there's no pushback operator anymore for Task.await right
I think you mean the "backpassing" (<-
) operator?
It's still there but we would like to remove it in the future
Last updated: Jul 05 2025 at 12:14 UTC