Has there been any discussion for more convenient backpassing syntax when the task returns {}
? I find it odd to have to write _ <- Stdout.line "hello" |> Task.await
. I'm sure you all know that Haskell "automatically" handles IO ()
in the do notation. I'm guessing this has been thought about and rejected?
Hi drew,
We created the !
syntax so you can avoid writing _ <-
. I believe most of it is (very recently) implemented, I'm not sure what from the design doc is still missing.
awesome thanks!
oh wow, the !
design is really nice. i like that it means everything looks like the direct style.
the with
syntax feels slightly awkward, but i like that !
builds on with
, and you'd _definitely_ want with
for parsers imo.
does with
work for the entire following block? can you nest with
s?
one downside to removing backpassing is that if you want to interleave effects you need to use explicit callbacks, right?
I think the current plan is to not add with
unless it gains a really clear need.
First we are making !
special to only tasks
Next thing we would consider is making !
valid for any andThen
style function on an opaque type (maybe a special case for result if needed). I don't think we can make it work with generic type aliases.
After that, we would look into with
if there is still need for more flexibility
And yes, this means that callback need to be explicit otherwise. At least once back passing is removed
got it, thanks.
Brendan Hansknecht said:
Next thing we would consider is making
!
valid for anyandThen
style function on an opaque type (maybe a special case for result if needed). I don't think we can make it work with generic type aliases.
how would this look for a non-Task type?
does this mean that whatever the returned type is, if it could be subsequently passed to that module's andThen
function, you could use !
?
This has been discussed some, but is not nailed down. A big question is if we want the complexity it could bring to the type system or simply to have a basic rewrite rule like what you mentioned by calling the module's andThen
function.
this couldn't be implemented directly via abilities because of higher-kinded types, yeah?
Exactly
just when you thought you were out, monads pull you back in
Yeah, maybe. Though having a single monad for a very specific scope is very different from totally generic monad use cases.
Though how !
and other syntax sugar work today, they are essentially macros. So they technically can skip past the type system and just generate something.
totally agreed. i do think having !
work with user defined types seems better.
+1
the !
reminds me a bit of letops in ocaml
I'm curious to see what specific use cases (if any) remain for user-defined types once we have shadowing and can use that for random number seeds
the seed-passing style is very straightforward and composes trivially with tasks; the main ergonomics problem there is naming
whereas the "build up a big Generator
" style clashes with tasks regardless of whether it has !
sugar - so I think once we have shadowing that will likely end up being the recommended style to use anyway
and I don't think !
would solve a significant pain point in parsers - e.g. Elm has neither that nor a backpassing equivalent and there doesn't seem to be demand for anything like that in Elm parsers
so if we have shadowing I'm not really sure what the specific use cases would be, what the delta in experience would be between having !
work and not work with those types, etc.
interesting. what’s the “seed passing style”?
and what do you mean by shadowing in this context?
Richard Feldman said:
I'm curious to see what specific use cases (if any) remain for user-defined types once we have shadowing and can use that for random number seeds
I was thinking that if we have !-syntax for custom types, it might come in useful in the design of roc-ci. To replace the backpassing I'm using in the current design. This is an example pipeline definition in the current design:
buildAndTest : Ci.Job
buildAndTest =
repoDetails <- Ci.step0 "setup git" Ci.setupGit
binary <- Ci.step1 "build binary" buildBinary repoDetails
testsPass <- Ci.step1 "run tests" runTests binary
_ <- Ci.step2 "release" release binary testsPass
Ci.done
This is using backpassing to do what would be called a writer monad in Haskell. Every CI step you create is recorded into some internal data structure, from which the CI runner can later extract it.
If the seed-passing-style is what I think it is, I imagine that might be an alternative here too. The internal data structure containing the steps would be passed explicitly around:
buildAndTest : Ci.Job
buildAndTest =
job = Ci.emptyJob
(repoDetails, job) = Ci.step0 job "setup git" Ci.setupGit
(binary, job) = Ci.step1 job "build binary" buildBinary repoDetails
(testsPass, job) = Ci.step1 job "run tests" runTests binary
(_, job) = Ci.step2 job "release" release binary testsPass
job
There is a downside to this approach though: it becomes possible to accidentally not record a CI step, particularly when you're not interested in the result of the CI step and so are _
-ing part of the return value of that step anyway. For instance, this would be a version of the previous example where the release
step at the end is dropped:
buildAndTest : Ci.Job
buildAndTest =
job = Ci.emptyJob
(repoDetails, job) = Ci.step0 job "setup git" Ci.setupGit
(binary, job) = Ci.step1 job "build binary" buildBinary repoDetails
(testsPass, job) = Ci.step1 job "run tests" runTests binary
_ = Ci.step2 job "release" release binary testsPass
job
Of course entirely different designs are possible too!
One big outstanding question I have about roc is how application-wide config / data will be handled. I don’t love the Reader monad but I’m not sure what other options are available. Many use Task and rely on a specific effect from the platform?
Another scenario where I think having !
for custom types could come in useful: defining a custom Task
type wrapping the future builtin, with tracing support.
Suppose you want to create a platform that contains the following primitive for recording a span in a trace:
span : Str, Task val err -> Task val err
In words: this takes a task and a string description, and would record how long the task takes to run. The platform could then add support for sending this data to some tracing platform where you can look at a flamegraph of your code.
For this to work the Task
would need to carry some metadata related to the current span:
In the current design where platforms define their own Task
type, platforms can define it to carry whatever metadata they like. If this is no longer possible because Task becomes a builtin (I'm not sure about this), another approach might be to define a custom Task
type by wrapping the builtin one, though if !
is not available for the custom Task
type it would not be very ergonomic.
drew said:
One big outstanding question I have about roc is how application-wide config / data will be handled. I don’t love the Reader monad but I’m not sure what other options are available. Many use Task and rely on a specific effect from the platform?
for this use case I agree with Evan's advice for what to do in Haskell (where Reader and others are available): just pass it around!
Richard Feldman said:
drew said:
One big outstanding question I have about roc is how application-wide config / data will be handled. I don’t love the Reader monad but I’m not sure what other options are available. Many use Task and rely on a specific effect from the platform?
for this use case I agree with Evan's advice for what to do in Haskell (where Reader and others are available): just pass it around!
Doesn't this mean passing something like a database connection to most non-pure functions in a large-ish web application?
yes, although with module params the passing doesn't have to be function-to-function, it can be module-to-module
I suspect that would be the more common way to pass it around in Roc
@Jasper Woudenberg could it make sense in a CI platform to use Task
instead of Job
?
alternatively, could an applicative-based API for Job
make sense? Then you could use record builder syntax, and also the structure would be statically inspectable (so you could print out a build graph etc)
Jasper Woudenberg said:
There is a downside to this approach though: it becomes possible to accidentally not record a CI step, particularly when you're not interested in the result of the CI step and so are
_
-ing part of the return value of that step anyway. For instance, this would be a version of the previous example where therelease
step at the end is dropped:buildAndTest : Ci.Job buildAndTest = job = Ci.emptyJob (repoDetails, job) = Ci.step0 job "setup git" Ci.setupGit (binary, job) = Ci.step1 job "build binary" buildBinary repoDetails (testsPass, job) = Ci.step1 job "run tests" runTests binary _ = Ci.step2 job "release" release binary testsPass job
Would it make sense to catch this by linting _ =
? I don't think there is much of a reason to write _ =
in Roc if it means performing a pure computation and this discarding the result
yeah I think that's a reasonable warning in general :thumbs_up:
could it make sense in a CI platform to use
Task
instead ofJob
?
Just thinking out loud of use cases that I expect to hit this (not that they need !
, but they could use backpassing today and !
would be really nice):
Not sure if there are any common higher level wrappers of Result that would benefit from this.
brief thoughts on those:
Task
is definitely going to support it no matter what :big_smile:)Task
state machine.) I haven't heard of other use cases for wrapping Task
that seem like a good idea, so I think the specifics matter on this one!Result
- neither Elm nor OCaml have sugar for this and it hasn't been an issue; I think it's fine if Roc doesn't have sugar for it either. Plus Result
isn't opaque in Roc, so it can't get abilities, making it innately awkward for any kind of "make !
work with any type that has a particular ability" design. If we really want it just for Result
we can always discuss a ?
suffix in addition to !
Generator
is a good idea for reuse (and in that use case, it doesn't clash with Task
), I'm specifically talking about the style of "running" the RNG where you wrap all the logic you want to do in a big Generator
that you have no intention of reusing, just as a way of threading the seed around. That's the situation where you can absolutely clash with Task
, and where using seed
instead doesn't have that problem.Logging can be done by providing a function to the platform, which it can then call when executing the
Task
state machine.
That may not give you the selective logging control you want. It also makes logging depend on the platform. It would be much nicer to build a logging library in roc that creates some form of logging task that simply has to know what effect to run for various kinds of logging. So I do think there is a rather large unlock here from allowing the wrapping.
I haven't heard of other use cases for wrapping
Task
that seem like a good idea
Yeah, not fully sure, the CI Job and general graph building + task style seems to be where this would most likely come up.
I'm specifically talking about the style of "running" the RNG where you wrap all the logic you want to do in a big
Generator
that you have no intention of reusing, just as a way of threading the seed around. That's the situation where you can absolutely clash withTask
, and where usingseed
instead doesn't have that problem.
I was specifically think about where you define a generator per type and they are composable. So an outer generator composes many sub generators. I guess it probably could all be direct calls. Past hiding state, I'm not sure if there is a major difference in apis.
is that stream in the sense of "iterators,"
Kinda. I was more specifically thinking lazy data creation. Made by a chain of continuations.
Richard Feldman said:
Result
- neither Elm nor OCaml have sugar for this and it hasn't been an issue; I think it's fine if Roc doesn't have sugar for it either. PlusResult
isn't opaque in Roc, so it can't get abilities, making it innately awkward for any kind of "make!
work with any type that has a particular ability" design. If we really want it just forResult
we can always discuss a?
suffix in addition to!
letops
are a syntax for this in ocaml that can work with user-defined types, right? and let-ppx
was before letops
were directly supported.
Brendan Hansknecht said:
Logging can be done by providing a function to the platform, which it can then call when executing the
Task
state machine.That may not give you the selective logging control you want. It also makes logging depend on the platform. It would be much nicer to build a logging library in roc that creates some form of logging task that simply has to know what effect to run for various kinds of logging. So I do think there is a rather large unlock here from allowing the wrapping.
there's a deeper discussion to be had on the topic of logging, but briefly - given that platforms can already log all I/O (and give application authors a way to specify how they want each particular I/O operation to be logged), I'm skeptical that it's really beneficial to be able to say "okay this exact HTTP request should be at a different log level than this exact other HTTP request" as opposed to (for example) filtering the logs in some other way, or setting the level as a function of information contained in the the HTTP request itself (e.g. if the URL is going to some known third-party analytics domain, those requests can always be set to a lower log level)
regarding OCaml, I actually don't know anything about letops
or let-ppx
:sweat_smile:
We'll have to see in practice once we have module params. My gut feel is that platform logging support will be adhoc and inconsistent. Including, I would guess that many platforms won't have proper logging at all. As such, it will be nicer to just call Stderr.line
or File.write
or Http.push
to send logs. Then it will be nicer to wrap that up in a shared library that takes in the logging primitive as a module param task. I think that is the most likely way to get a really nice and consistent logging experience that can be attached to any platform with some sort of text output primitive.
That said, I'm not sure that you will actually need any special form of wrapping task for that.
So it may not need any sort of special !
Richard Feldman said:
regarding OCaml, I actually don't know anything about
letops
orlet-ppx
:sweat_smile:
Here's a little example I put together to show how letops
work -- https://gist.github.com/drewolson/5405ad83e5303026650d4015d185490c
It feels worth explaining as letops
actually feel pretty close to what you're describing with !
-- they work as basically "rewrites". They effectively make callback based code look like the direct style, using whatever definition of let*
(or other let
-style functions) are in scope.
in the example I show using let*
(direct-style bind) for both lists and options, just as an example
the annoying downside is that you must explicitly bring into scope the particular implementation you want for a given function
that said, at least it is explicit. it feels very similar to the proposal for with
and ?
.
the same tools are used for applicative-style APIs via the conventionally named let+
and and+
here's an example of an applicative-style parser using letops
and the angstrom
library https://gist.github.com/drewolson/1776047f9ac00d51df8a7a36deb2fda6
the comparisons to ocaml feel useful as it is a language without ad hoc polymorphism at all :) but i get that the design goals of roc differ. just wanted it here as a comparison / prior art.
Writing this small parser also made me remember how much i dislike full type inference in ocaml, related to one of the disadvantages of HKP in the FAQ. If I accidentally forget the labeled argument to Angstrom.parse_string
, here's the helpful error message I get:
File "bin/parse/main.ml", line 21, characters 4-11:
21 | | Error _ -> print_endline "Error!"
^^^^^^^
Error: This pattern should not be a constructor, the expected type is
consume:Angstrom.Consume.t -> (int * int, string) result```
though i suppose this is actually related to currying :)
Richard Feldman said:
Jasper Woudenberg could it make sense in a CI platform to use
Task
instead ofJob
?
Richard Feldman said:
alternatively, could an applicative-based API for
Job
make sense? Then you could use record builder syntax, and also the structure would be statically inspectable (so you could print out a build graph etc)
Yeah, I'd like to make the CI structure be statically inspectable, which is why I don't think using Task
instead of Job
would help. With regards to record builders, I've thought about it after getting that feedback before, but I'm not sure what record I'd want to build :sweat_smile:. Would the idea be to try represent the build graph as a set of nested records?
What I like about the design I have now is that it's already statically inspectable. on account of Job
not coming with an andThen
. And it allows you to write the project's CI almost like an ordinary function, with roc-ci being able to turn that into a pipeline (i.e., we can automatically figure out which bits to run in paralllel).
Jasper Woudenberg said:
What I like about the design I have now is that it's already statically inspectable. on account of
Job
not coming with anandThen
. And it allows you to write the project's CI almost like an ordinary function, with roc-ci being able to turn that into a pipeline (i.e., we can automatically figure out which bits to run in paralllel).
ah! So !
desugars into an andThen
, so it probably wouldn't be usable here even if it did support types other than Task
:big_smile:
So logging is one thing, but any thoughts about what it would take to have good tracing support? For me, traces have completely replaced logging in web applications, and I'd be happy never to have to think off log levels again :sweat_smile:. I think in terms of functionality required, while logging could be implemented by the platform providing some log function(s), for tracing the Task
type needs to carry some context to allow a tree of spans to be created. Will that be possible with the language-provided Task
type?
Richard Feldman said:
ah! So
!
desugars into anandThen
, so it probably wouldn't be usable here even if it did support types other thanTask
:big_smile:
Ah, that makes sense, I guess that would never work then.
I'll probably go with the shadowing approach then, seems pretty straight-forward and I think it'd likely have better errors then the current backpassing approach anyway.
cool cool!
out of curiosity, how do you think you'd do it in Elm? :thinking:
Elm uses Task
so little, I'm not sure there'd be a ton of value in instrumenting it (given it'd leave most of the application out of scope). That said, if I had to, I'd probably introduce a custom Task
type, give it the same API as the built-in task. That custom Task
type could have a structure like this:
type TracingTask err val = TracingTask (TraceContext -> Task err val)
type alias TraceContext = { id: String, startTime : Time }
For a more full-formed example, we defined a Task
type in a similar fashion around IO
in nri-haskell
:
https://github.com/NoRedInk/haskell-libraries/blob/trunk/nri-prelude/src/Platform/Internal.hs#L63
oh I meant modeling Job
in Elm
since Elm doesn't have shadowing
Richard Feldman said:
oh I meant modeling
Job
in Elm
Ah gotcha. Yeah, good question. Maybe define Job.map2
and Job.map3
and the like?
Nice question, I'll think a bit about this.
in playing around with !
, i've found that ending a program in the basic-cli with _two_ statements that both use !
works fine, but a single statement ending in !
to end the program produces a type error
see https://gist.github.com/drewolson/635f4c39bd209338d3f6e5030720f1a9
output from the non-working case:
$ roc dev
── TYPE MISMATCH in main.roc ───────────────────────────────────────────────────
This 2nd argument to this function has an unexpected type:
22│ Stdout.line! "Hello, $(name)"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The argument is an anonymous function of type:
{} -> {}
But this function needs its 2nd argument to be:
{} -> InternalTask.Task c *
Tip: Type comparisons between an opaque type are only ever equal if
both types are the same opaque type. Did you mean to create an opaque
type by wrapping it? If I have an opaque type Age := U32 I can create
an instance of this opaque type by doing @Age 23.
────────────────────────────────────────────────────────────────────────────────
1 error and 0 warnings found in 27 ms
.
You can run the program anyway with roc run```
Thank you, can you please log an issue. I'm pretty sure I know what is going wrong in this case
Just a link to the gist is all the description needed
sure, make the issue in the roc repo?
done, it's here https://github.com/roc-lang/roc/issues/6661
Last updated: Jul 06 2025 at 12:14 UTC