(forked from https://roc.zulipchat.com/#narrow/stream/231634-beginners/topic/Task.2Ferror.20handling.20.26.20platform.20use/near/314385634)
At least basic-cli main to have the signature main : Task {} []
, which I take to mean that we can't leave any errors unhandled. I've seen the available options in the Task package (such as await vs attempt, onFail, etc), but, at least at my experience level they don't seem to combine well.
For example, the tutorial extensively highlights await
, though...
await
, afaict, prevents the remainder of the surrounding function from being able to handle any errors that came out of the application of awaited task.attempt
does allow handling errors in the same function, but is not nearly as convenient, and must be paired with Result
functions (not too bad) or when
.when
expression (for error checking) disrupts the visual flow (and thus readability/pacing) of what otherwise might be a nicely pipeline or back-passed function.I'm hoping someone can share some technique(s) for use in throwaway/non-productionized programs ("scripts"), such as in Advent of Code solutions, in which I can conveniently use backpassing with await
throughout main
, while being able to capture errors and log a generic failure message to Stderr.
For serious programs, I would of course take errors seriously, but at present I do find myself regularly switching a given function back and forth between Task functions (such as backpassing await vs backpassing attempt vs forward-piping onFail to attempt) in order to find a good balance of succinctness and simplicity while satisfying platform requirements.
one idea: we could change basic-cli
to accept main : Task {} *
- and then have it crash automatically for unhandled errors
this would be convenient for quick scripts, and if you wanted to force error handling, it would be as trivial as annotating your application with main : Task {} []
like today
Generally, what i have found pretty reasonable is to make a task that is chained with await. Only at the very end use a when
to do any error handling. If no error handling is wanted, I think you can just crash
on all errors.
Theoretically, it should be possible to chain a Task.onFail
to deal with error handling (again, this could just be a call to crash
). That said, i would need to double check if this is convenient or if the types don't exactly align happily.
We also could just expose something like Task.crashOnErr
to the end user that would print the error and crash. Requires a way to print tag unions first, but in general should work
@Brendan Hansknecht by chained, do you mean |>
?
I could see how that'd work, but isn't that still also mutually exclusive with a predominant backpassing task construction style? I couldn't figure out how to make the type system work with await, backpassing, _and_ error handling.
I may just be trying to over-leverage backpassing + await when backpassing + attempt or chaining + await would be more suitable for these situations?
It's been the better part of a year since I've given the tutorial a full read through, but in my recent skimming, it seemed like await + backpassing were prominently advertised together without mention of caveats, so I figured that combination was the most conventional choice whenever tasks were involved.
I'm still having a hard time sometimes getting tasks to work together. I spent a fair amount of time working with them; but it still hasn't fully clicked for me. Below is a concrete example; I have been trying for about 15mins now to figure out what is causing this error. I am sure it is something really obvious that I am missing here.
I thought I would share as this is probably a common experience.
I am not sure which await
has the issue here.
main : Task {} []
main =
task =
fileInput <- File.readUtf8 (Path.fromStr "input-day-15.txt") |> Task.await
fileData = parseInput (Str.toUtf8 fileInput)
{} <- part1 (withColor "Part 1 Sample:" Green) {initialState & data : sampleData} 10 |> Task.await
{} <- part1 (withColor "Part 1 File:" Green) {initialState & data : fileData} 2_000_000 |> Task.await
Stdout.line "Complete"
Task.onFail task \_ -> crash "Oops, something went wrong."
part1 : Str, State, I64 -> Task {} []
% roc dev day15.roc
── TYPE MISMATCH ─────────────────────────────────────────────────── day15.roc ─
This 2nd argument to await has an unexpected type:
22│> fileInput <- File.readUtf8 (Path.fromStr "input-day-15.txt") |> Task.await
23│> fileData = parseInput (Str.toUtf8 fileInput)
24│> {} <- part1 (withColor "Part 1 Sample:" Green) {initialState & data : sampleData} 10 |> Task.await
25│> {} <- part1 (withColor "Part 1 File:" Green) {initialState & data : fileData} 2_000_000 |> Task.await
26│>
27│> Stdout.line "Complete"
The argument is an anonymous function of type:
Str -> Task {} []
But await needs its 2nd argument to be:
Str -> Task {} [FileReadErr Path.Path InternalFile.ReadErr,
FileReadUtf8Err Path.Path [BadUtf8 Utf8ByteProblem Nat]]
Tip: Looks like a closed tag union does not have the FileReadErr and
FileReadUtf8Err tags.
Tip: Closed tag unions can't grow, because that might change the size
in memory. Can you use an open tag union?
────────────────────────────────────────────────────────────────────────────────
1 error and 0 warnings found in 25 ms.
does part1 does more file reading stuff? Im not sure what is going on there but I think we need to see more of the code.
I got it working. If I handle the error case for File.readUtf8
it works fine. I guess my understanding was that errors would flow all the way up to the task
and then get captured in the Task.onFail task \_ -> crash "Oops, something went wrong."
Changing it to the following works now. It was a little confusing; as I've used this same pattern for the last 14 AoC days and haven't had this issue before.
fileInput <-
File.readUtf8 (Path.fromStr "input-day-15.txt")
|> Task.mapFail \_ -> crash "couldnt read file"
|> Task.await
fileData = parseInput (Str.toUtf8 fileInput)
Hmmm it may have been one of the earlier awaits. Since part1 says it returns a task with no error tags. But at that point the task flowing through still has the file read errors as apart of its type. I think that could explain why adding the crash earlier takes care of it.
I wanna see if I can get that rudimentary language server going being able to hover the types here might help.
To test my theory u could remove the early crash and change the task type on part1
To test my theory u could remove the early crash and change the task type on part1
Yep, that works. Having the definition on part1
meant that it couldn't flow the error all the way through. I guess I added that to assist with fault finding something else, which then causes this issue.
Ooh, I just realised that if I handle the errors correctly, then I can simplify the whole main to the following. After playing with it a while, I really like this pattern!
main : Task {} []
main =
fileInput <-
File.readUtf8 (Path.fromStr "input-day-15.txt")
|> Task.mapFail \_ -> crash "couldn't read input"
|> Task.await
fileData = parseInput (Str.toUtf8 fileInput)
{} <- part1 "Sample" {initState & data : sampleData} 10 |> Stdout.line |> Task.await
{} <- part1 "File" {initState & data : fileData} 2_000_000 |> Stdout.line |> Task.await
Stdout.line "Completed processsing 😊"
part1 : Str, State, I64 -> Str
Erik, I think you're observation about seeing the types is a good one. If we could "see" the types flow through the code then that would be awesome.
I thought we made it so that all returned tags are now open, so i am a bit surprised by this error. That said, don't have access to a computer right now, so can't mess with it to figure these things out.
Interesting, ok I narrowed down the example to not require anything more than the following
app "main"
packages { pf: "https://github.com/roc-lang/basic-cli/releases/download/0.1.3/5SXwdW7rH8QAOnD71IkHcFxCmBEPtFSLAIkclPEgjHQ.tar.br" }
imports [
pf.File,
pf.Path,
pf.Stdout,
pf.Task.{ Task },
]
provides [main] to pf
main : Task {} []
main =
task =
_fileInput <- File.readUtf8 (Path.fromStr "some-file.txt") |> Task.await
{} <- part1 ("Some Str") { data: "Some Data" } 10 |> Task.await
{} <- part1 ("Some Str") { data: "Some Data" } 2_000_000 |> Task.await
Stdout.line "Complete"
Task.onFail task \_ -> crash "Oops, something went wrong."
part1 : Str, { data : Str }, I64 -> Task {} []
which reproduces the error
❯ roc check
── TYPE MISMATCH ──────────────────────────────────────────────────── main.roc ─
This 2nd argument to await has an unexpected type:
14│> _fileInput <- File.readUtf8 (Path.fromStr "some-file.txt") |> Task.await
15│>
16│> {} <- part1 ("Some Str") { data: "Some Data" } 10 |> Task.await
17│> {} <- part1 ("Some Str") { data: "Some Data" } 2_000_000 |> Task.await
18│>
19│> Stdout.line "Complete"
The argument is an anonymous function of type:
Str -> Task {} []
But await needs its 2nd argument to be:
Str -> Task {} [FileReadErr Path.Path InternalFile.ReadErr,
FileReadUtf8Err Path.Path [BadUtf8 Utf8ByteProblem Nat]]
Tip: Looks like a closed tag union does not have the FileReadErr and
FileReadUtf8Err tags.
Tip: Closed tag unions can't grow, because that might change the size
in memory. Can you use an open tag union?
────────────────────────────────────────────────────────────────────────────────
1 error and 0 warnings found in 13 ms.
I thought changing the annotation on part1 to return Task {} []*
might work out to make it explicitely open. But then I get.
❯ roc check
── UNNECESSARY WILDCARD ───────────────────────────────────────────── main.roc ─
This type annotation has a wildcard type variable (*) that isn't
needed.
23│ part1 : Str, { data : Str }, I64 -> Task {} []*
^
Annotations for tag unions which are constants, or which are returned
from functions, work the same way with or without a * at the end. (The
* means something different when the tag union is an argument to a
function, though!)
You can safely remove this to make the code more concise without
changing what it means.
────────────────────────────────────────────────────────────────────────────────
0 errors and 1 warning found in 13 ms.
Which makes me think I don't fully understand open tag unions :sweat_smile:
To clarify, I would have expected 0 warnings for that change since the above error made it seem like that tag union needed to be open (or explicitly include those error tags.
So still haven't had time to dig into this on a computer, but i think the type of await is pretty telling
Task a err, (a -> Task b err) -> Task b err
Even if the return type tag is always open, the Task a err
is an input to the function, this doesn't have to be open.
On top of that, the 2 error types have to unify and be represented in the same final memory layout based on how await is written. Still not totally sure the ramifications of this/if we can make it more flexible, but just some minor musings.
Im away from my laptop, but I had a thought, I feel like you need to remove the type definition on main
?
That shouldn't change anything. The type of main is specified in the platform.
So you can't really change it.
Oh, the original code works with a minor change. Just needs this type signature:
part1 : Str, State, I64 -> Task {} *
This definitely surprises me since I though we made open tag unions the default for return types. That would mean that returning []
should be the same as using *
to my understanding, but that is obviously wrong based on this example.
@Ayaz Hafiz any insights to help us understand the types here especially with the default of returning open tag unions?
Why does part1 : Str, State, I64 -> Task {} *
work, but part1 : Str, State, I64 -> Task {} []
fail to type check? I still assume this is somehow tied to it being consumed by await
. So it isn't really about returning open tag unions. It's about how await
is consuming and restricting the tag union. Just don't understand the full picture.
OK maybe this has something to do with it.... when the thing is being sent through await
where the return is a Task {} []
it raises this issue. But if the error tag union has just one tag in it the error goes away.
app "main"
packages { pf: "https://github.com/roc-lang/basic-cli/releases/download/0.1.3/5SXwdW7rH8QAOnD71IkHcFxCmBEPtFSLAIkclPEgjHQ.tar.br" }
imports [
pf.File,
pf.Path,
pf.Stdout,
pf.Task.{ Task },
]
provides [main] to pf
main : Task {} []
main =
task =
_fileInput <- File.readUtf8 (Path.fromStr "some-file.txt") |> Task.await
_ <- File.writeBytes (Path.fromStr "myfile.dat") [1, 2, 3] |> Task.await
{} <- iDontActuallyKnowWhatImDoing |> Task.await
Stdout.line "Donezo"
Task.onFail task \_ -> crash "boom"
iDontActuallyKnowWhatImDoing : Task {} [Hmm]
iDontActuallyKnowWhatImDoing = Task.succeed {}
iDontActuallyKnowWhatImDoing : Task {} [Hmm]
typechecks but iDontActuallyKnowWhatImDoing : Task {} []
does not
If Roc is encouraging people to crash on any errors because they can't figure out another way then it's a problem we need to fix! The language should be encouraging the opposite - handling everything.
I would suggest trying this pattern:
Put all the logic into a helper function whose return task type has all of the possible error tags in it.
Then all main does is call that helper and handle the returned errors. It would have a when..is
to turn the success or any errors into strings, then print it out.
Since main handles all the errors it can return a task with no errors.
I suppose that's similar to what Erik is doing with the inner task
I'm on my phone now but can try it on a computer later
OK this is what I meant but it gives the same error
app "main"
packages { pf: "https://github.com/roc-lang/basic-cli/releases/download/0.1.3/5SXwdW7rH8QAOnD71IkHcFxCmBEPtFSLAIkclPEgjHQ.tar.br" }
imports [
pf.File,
pf.Path,
pf.Stdout,
pf.Task.{ Task },
]
provides [main] to pf
main : Task {} []
main =
run
|> Task.onFail handleErrors
run : Task {} [FileReadErr Path.Path InternalFile.ReadErr, FileReadUtf8Err Path.Path [BadUtf8 _ Nat]]
run =
_fileInput <- File.readUtf8 (Path.fromStr "some-file.txt") |> Task.await
{} <- part1 ("Some Str") { data: "Some Data" } 10 |> Task.await
{} <- part1 ("Some Str") { data: "Some Data" } 2_000_000 |> Task.await
Stdout.line "Complete"
handleErrors : [FileReadErr Path.Path InternalFile.ReadErr, FileReadUtf8Err Path.Path [BadUtf8 _ Nat]] -> Task {} []
handleErrors = \error ->
errorString =
when error is
FileReadErr path _ ->
pathStr = Path.display path
"Could not read the file \(pathStr)"
FileReadUtf8Err path _ ->
pathStr = Path.display path
"Invalid UTF8 in file \(pathStr)"
Stdout.line errorString
part1 : Str, { data : Str }, I64 -> Task {} []
this is a bug in specifically how empty tag unions are handled. For example part1 : Str, { data : Str }, I64 -> Task {} [FileReadErr _ _ ]
works. The empty tag union case part1 : Str, { data : Str }, I64 -> Task {} []
should work too.
Ok, this is a bug. I thought this seemed off.
Thanks
FWIW after all of this discussion and exploring, I think the current design is really great. My example above feels pretty simple conceptually and it encourages you to handle the errors so they don't accumulate. I think a good introduction with plenty of examples will be sufficient. Also, additional tooling support like an IDE would be nice to have in the future. The error msg could maybe be improved a liite more, im not sure what's possible here though.
Whether you handle the errors right away, handle them latter, or just crash since it is a quick script, the original error example should type check. It is still a bug specifically around how we handle the empty tag. It should be considered open because it is in a return type. Since it is not considered open it doesn't merge/type check. Including Brian's proper error checking example wouldn't type check in current roc without changing the type of part1.
Yep good summary.
It certainly did seem like a bug. Thanks for confirming that, Ayaz!
I checked naive type on hover, it doesn't even really help here.. I feel like the editor could do a much nicer job, at least telling you where the type inference went wrong interactively (e.g. linking the relevant part of the part1
signature vs the specific problematic part of the backpacking, like the error message but interactive), which even in this compiler bug scenario would make it much easier to recognize as a bug
Awesome thanks everyone for confirming the bug and the tips/tricks :).
Last updated: Jul 05 2025 at 12:14 UTC