Tasks & Error Handling
Explanation
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.7.0/bkGby8jb0tmZYsy2hg1E_B2QrCgcSTxdUlHtETwm5m4.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: