Stream: beginners

Topic: Effective error handling techniques


view this post on Zulip Kevin Gillette (Dec 09 2022 at 09:34):

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

  1. 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.
  2. 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.
  3. A 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.

view this post on Zulip Richard Feldman (Dec 09 2022 at 12:24):

one idea: we could change basic-cli to accept main : Task {} * - and then have it crash automatically for unhandled errors

view this post on Zulip Richard Feldman (Dec 09 2022 at 12:25):

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

view this post on Zulip Brendan Hansknecht (Dec 09 2022 at 15:41):

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.

view this post on Zulip Brendan Hansknecht (Dec 09 2022 at 15:43):

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

view this post on Zulip Kevin Gillette (Dec 09 2022 at 19:18):

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

view this post on Zulip Luke Boswell (Dec 17 2022 at 00:37):

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.

view this post on Zulip Erik (Dec 17 2022 at 00:53):

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.

view this post on Zulip Luke Boswell (Dec 17 2022 at 00:58):

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)

view this post on Zulip Erik (Dec 17 2022 at 01:07):

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.

view this post on Zulip Erik (Dec 17 2022 at 01:08):

I wanna see if I can get that rudimentary language server going being able to hover the types here might help.

view this post on Zulip Erik (Dec 17 2022 at 01:09):

To test my theory u could remove the early crash and change the task type on part1

view this post on Zulip Luke Boswell (Dec 17 2022 at 01:23):

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.

view this post on Zulip Luke Boswell (Dec 17 2022 at 01:34):

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

view this post on Zulip Luke Boswell (Dec 17 2022 at 01:40):

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.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 02:09):

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.

view this post on Zulip Erik (Dec 17 2022 at 04:29):

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 {} []

view this post on Zulip Erik (Dec 17 2022 at 04:31):

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:

view this post on Zulip Erik (Dec 17 2022 at 04:33):

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.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 05:06):

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.

view this post on Zulip Luke Boswell (Dec 17 2022 at 06:01):

Im away from my laptop, but I had a thought, I feel like you need to remove the type definition on main?

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 06:08):

That shouldn't change anything. The type of main is specified in the platform.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 06:08):

So you can't really change it.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 06:15):

Oh, the original code works with a minor change. Just needs this type signature:
part1 : Str, State, I64 -> Task {} *

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 06:16):

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.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 06:19):

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

view this post on Zulip Erik (Dec 17 2022 at 06:45):

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

view this post on Zulip Erik (Dec 17 2022 at 06:46):

iDontActuallyKnowWhatImDoing : Task {} [Hmm] typechecks but iDontActuallyKnowWhatImDoing : Task {} [] does not

view this post on Zulip Brian Carroll (Dec 17 2022 at 10:29):

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.

view this post on Zulip Brian Carroll (Dec 17 2022 at 10:30):

I suppose that's similar to what Erik is doing with the inner task

view this post on Zulip Brian Carroll (Dec 17 2022 at 10:31):

I'm on my phone now but can try it on a computer later

view this post on Zulip Brian Carroll (Dec 17 2022 at 10:58):

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 {} []

view this post on Zulip Ayaz Hafiz (Dec 17 2022 at 13:45):

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.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 15:44):

Ok, this is a bug. I thought this seemed off.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 15:44):

Thanks

view this post on Zulip Luke Boswell (Dec 17 2022 at 20:20):

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.

view this post on Zulip Brendan Hansknecht (Dec 17 2022 at 21:17):

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.

view this post on Zulip Brian Carroll (Dec 17 2022 at 21:39):

Yep good summary.
It certainly did seem like a bug. Thanks for confirming that, Ayaz!

view this post on Zulip Ayaz Hafiz (Dec 18 2022 at 01:08):

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

image.png

view this post on Zulip Erik (Dec 18 2022 at 03:39):

Awesome thanks everyone for confirming the bug and the tips/tricks :).


Last updated: Jul 05 2025 at 12:14 UTC