Tasks & Error Handling

This example shows how to use Task with the basic-cli platform. We'll explain how tasks work while demonstrating how to read command line arguments and environment variables, write files, and fetch content through HTTP.

We recommend you read the tasks and backpassings sections in the tutorial first and open up the documentation for the basic-cli platform on the side.

Remember; a Task represents an effect; an interaction with state outside your Roc program, such as the terminal's standard output, or a file.

Below we'll introduce the example code step by step, you can check out the full code at any time at the bottom.

main

The roc-lang/basic-cli platform requires an application to provide a Task, namely main : Task {} *. This task usually represents a sequence or combination of Tasks, and will resolve to an empty record {}. This is similar to void or unit in other programming languages.

The main task is run by the platform when the application is executed. It cannot return errors, which is indicated by the *.

For this example, we'll be using the following main:

main : Task {} *
main =
    run
    |> Task.onErr handleErr

# Error : [ FailedToReadArgs, FailedToFetchHtml Str, ... ]

handleErr : Error -> Task {} *

run : Task {} Error

The run : Task {} Error task resolves to a success value of an empty record, and if it fails, returns with our custom Error type.

This simplifies error handling so that a single handleErr function can be used to handle all the Error values that could occur.

run

We want to see how fast our app runs, so we'll start our run Task by getting the current time.

startTime <- Utc.now |> Task.await

To get the current time, we need to interact with state outside of the roc program. We can not just calculate the current time, so we use a task, Utc.now. It's type is Task Utc *. The task resolves to the UTC time (since Epoch). Task.await allows us to chain tasks. We use this common backpassing pattern ... <- ... |> ... to make it look similar to startTime = Utc.now for readability.

Read an environment variable

Next up in the task chain we'll read the environment variable HELLO:

helloEnvVar <- readEnvVar "HELLO" |> Task.await

# …

readEnvVar : Str -> Task Str *

And print it (to stdout):

{} <- Stdout.line "HELLO env var was set to $(helloEnvVar)" |> Task.await

The {} <- looks different then the previous steps of the chain, that's because the type of Stdout.line is Str -> Task {} *. The Task resolves to nothing (= the empty record) upon success. You can't just do Stdout.line "HELLO env var was set to $(helloEnvVar)" without {} <- ... |> Task.await like you could in other languages. By keeping it in the task chain we know which roc code is pure (no side-effects) and which isn't, which comes with a lot of benefits!

Command line arguments

When reading command line arguments, it's nice to be able to read multiple arguments. We can use record destructuring to fit these multiple arguments nicely in our chain:

{ url, outputPath } <- readArgs |> Task.await

# …

readArgs : Task { url: Str, outputPath: Path } [FailedToReadArgs]_

Notice that readArgs can actually return an error unlike the previous tasks, namely FailedToReadArgs. With our Task.await chain we can deal with errors at the end so it doesn't interrupt the flow of our code right now.

The underscore (_) at the end of [FailedToReadArgs] is a temporary workaround for an issue.

Fetch website content

We'll use the url we obtained in the previous step and retrieve its contents:

strHTML <- fetchHtml url |> Task.await

Write to a file

Next up, we'll write our strHTML to a file located at outputPath.

{} <- 
    File.writeUtf8 outputPath strHTML
    |> Task.onErr \_ -> Task.err (FailedToWriteFile outputPath)
    |> Task.await

The File.writeUtf8 task resolves to an empty record if the provided Str is sucessfully written, so we're using {} <- again. The error type for writeUtf8 is [FileWriteErr Path WriteErr] but we'd like to replace it with our own simpler error here. For that we use Task.onErr.

List the contents of a directory

We're going to finish up with something more involved:

    {} <- 
        listCwdContent
        |> Task.map \dirContents ->
            List.map dirContents Path.display
            |> Str.joinWith ","

        |> Task.await \contentsStr ->
            Stdout.line "Contents of current directory: $(contentsStr)"

        |> Task.await 

# …

listCwdContent : Task (List Path) [FailedToListCwd]_

We call listCwdContent to list all files and folders in the current directory. Next, we take this list of paths, turn them all into Str using Path.display, and join/concatenate this list with a ",". We use Task.map to transform the success value of a Task into something that is not a Task, a Str in this case. Take a minute to look at the similarities and differences of Task.map and Task.await:

Task.map : Task a b, (a -> c) -> Task c b

Task.await : Task a b, (a -> Task c b) -> Task c b

Next, we write our Str of combined dirContents to Stdout. We use Task.await because we're passing it a function that returns a Task with Stdout.line.

We end with |> Task.await to complete the typical backpassing pattern.

Feedback

Tasks are important in roc, we'd love to hear how we can further improve this example. Get in touch on our group chat or create an issue.

Full Code

app "task-usage"
    packages { 
        pf: "https://github.com/roc-lang/basic-cli/releases/download/0.8.1/x8URkvfyi9I0QhmVG98roKBUs_AZRkLFwFJVJ3942YA.tar.br"
    }
    imports [ pf.Stdout, pf.Stderr, pf.Arg, pf.Env, pf.Http, pf.Dir, pf.Utc, pf.File, pf.Path.{ Path }, pf.Task.{ Task } ]
    provides [ main ] to pf

run : Task {} Error
run =

    # Get time since [Unix Epoch](https://en.wikipedia.org/wiki/Unix_time)
    startTime <- Utc.now |> Task.await

    # Read the HELLO environment variable
    helloEnvVar <- readEnvVar "HELLO" |> Task.await

    {} <- Stdout.line "HELLO env var was set to $(helloEnvVar)" |> Task.await
    
    # Read command line arguments
    { url, outputPath } <- readArgs |> Task.await

    {} <- Stdout.line "Fetching content from $(url)..." |> Task.await
    
    # Fetch the provided url using HTTP
    strHTML <- fetchHtml url |> Task.await

    {} <- Stdout.line "Saving url HTML to $(Path.display outputPath)..." |> Task.await

    # Write HTML string to a file
    {} <- 
       File.writeUtf8 outputPath strHTML
       |> Task.onErr \_ -> Task.err (FailedToWriteFile outputPath)
       |> Task.await

    # Print contents of current working directory
    {} <- 
        listCwdContent
        |> Task.map \dirContents ->
            List.map dirContents Path.display
            |> Str.joinWith ","

        |> Task.await \contentsStr ->
            Stdout.line "Contents of current directory: $(contentsStr)"

        |> Task.await 
    
    endTime <- Utc.now |> Task.await
    runTime = Utc.deltaAsMillis startTime endTime |> Num.toStr

    {} <- Stdout.line "Run time: $(runTime) ms" |> Task.await

    # Final task doesn't need to be awaited
    Stdout.line "Done"

# NOTE in the future the trailing underscore `_` character will not be necessary.
# This is a temporary workaround until [this issue](https://github.com/roc-lang/roc/issues/5660) 
# is resolved.

readArgs : Task { url: Str, outputPath: Path } [FailedToReadArgs]_
readArgs =
    args <- Arg.list |> Task.attempt
    
    when args is
        Ok ([ _, first, second, .. ]) ->
            Task.ok { url: first, outputPath: Path.fromStr second }
        _ ->
            Task.err FailedToReadArgs

readEnvVar : Str -> Task Str *
readEnvVar = \envVarName ->
    envVarResult <- Env.var envVarName |> Task.attempt

    when envVarResult is
        Ok envVarStr if !(Str.isEmpty envVarStr) ->
            Task.ok envVarStr
        _ ->
            Task.ok ""

fetchHtml : Str -> Task Str [FailedToFetchHtml Str]_
fetchHtml = \url ->
    { Http.defaultRequest & url } 
    |> Http.send 
    |> Task.onErr \err ->
        Task.err (FailedToFetchHtml (Http.errorToString err)) 

listCwdContent : Task (List Path) [FailedToListCwd]_
listCwdContent = 
    Path.fromStr "."
    |> Dir.list
    |> Task.onErr \_ -> Task.err FailedToListCwd

main : Task {} *
main =
    run
    |> Task.onErr handleErr

Error : [
    FailedToReadArgs,
    FailedToFetchHtml Str,
    FailedToWriteFile Path,
    FailedToListCwd,
]

handleErr : Error -> Task {} *
handleErr = \err ->
    usage = "HELLO=1 roc main.roc -- \"https://www.roc-lang.org\" roc.html"

    errorMsg = when err is
        FailedToReadArgs -> "Failed to read command line arguments, usage: $(usage)"
        FailedToFetchHtml httpErr -> "Failed to fetch URL $(httpErr), usage: $(usage)"
        FailedToWriteFile path -> "Failed to write to file $(Path.display path), usage: $(usage)"
        FailedToListCwd -> "Failed to list contents of current directory, usage: $(usage)"

    Stderr.line "Error: $(errorMsg)"

Output

Run this from the directory that has main.roc in it:

$ HELLO=1 roc examples/Tasks/main.roc -- "https://www.roc-lang.org" roc.html
HELLO env var was set to 1
Fetching content from https://www.roc-lang.org...
Saving url HTML to roc.html...
Contents of current directory: [...]
Run time: 329 ms
Done