roc-lang / roc

A fast, friendly, functional language.
https://roc-lang.org
Universal Permissive License v1.0
3.88k stars 289 forks source link

Add function `catch : Result ok err, (err -> a), (ok -> a) -> a` to standard library. #6759

Open lalaithion opened 1 month ago

lalaithion commented 1 month ago

While writing multiple toy Roc programs, I always write this function very early. For example, in the following code in a dispatcher for a web server with multiple endpoints, I want to handle the error case by returning a 404 response.

endpoint <- Dict.get endpoints (url, method)
  |> Result.catch (\_ -> textResponse 404 "Not found")
# do something with `endpoint` to produce an http response

Using try here would require that I turn errors into web responses far away from the result, for example:

endpoint <- Dict.get endpoints (url, method)
  |> Result.mapErr (\_ -> errorNotFound)
  |> Result.try
# do something with `endpoint` to produce an http response
# Down here somewhere I need to match on the error and turn `errorNotFound` into `textResponse 404 "Not found"`

This has prior art in Haskell called either, in Rust called map_or_else, and is a very similar pattern to returning early on error or exception in most imperative languages, e.g. in golangish pseudocode:

endpoint, ok := endpoints[endpointName{url, method}]
if !ok {
  response.WriteHeader(http.StatusNotFound)
  return
}
// do something with `endpoint` to produce an http response

Names considered: catch (using the already-existent reference to try catch blocks in the function try), handle (as in "handling" the error), result (similar to Haskell's either naming), mapOrElse (taken from Rust), earlyReturn (similar to imperative languages). In the end I don't love any of these names, but handle or catch are my favorites.

Reasons not to add this: It's a four-line implementation and any methods added at this point may become less useful if the language evolves in an unexpected direction, and removing things from the standard library is painful even if backwards compatibility is not promised yet.

Implementation:

catch : Result a b, (b -> c), (a -> c) -> c
catch = \result, onErr, onOk -> when result is 
    Ok a -> onOk a
    Err b -> onErr b
Anton-4 commented 1 month ago

Thanks for your contribution @lalaithion!

catch could be a good addition, one observation that I have right now is that Result.withDefault does seem like the best way to handle your 404 example. To move to a self-contained example:

numDict =
    Dict.fromList [(1, "One"), (2, "Two")]

main =
    numStr =
        Dict.get numDict 3
        |> Result.withDefault "Not found"

    Stdout.line! numStr

Feel free to correct me if I have this wrong!

lalaithion commented 1 month ago

There are many cases where withDefault can be used, but it has two shortcomings that motivated me to write catch many times. Firstly, it doesn't allow the default value to be constructed with information from the error. In the missing dictionary entry case this is trivial, but sometimes the error may include important information for generating an error response. Secondly, withDefault is local. In the following example, we don't want to continue execution with a default value, but terminate the enclosing execution with a default value:

main = 
  arg <- Arg.list! |> List.get 1 |> Result.catch (\e -> Stdout.line! "no arg provided")
  val <- Env.get! arg |> Result.catch(\e -> Stdout.line! "variable not found")
  i <- Str.toI64 i |> Result.catch (\e -> Stdout.line! "variable was not a number")
  Stdout.line! (Num.toStr)

Compare this to

main = 
  res = arg <- Arg.list! |> List.get 1 |> Result.try
    val <- Env.get! arg |> Result.try
    i <- Str.toI64 |> Result.try

  when res is 
     Ok i -> Stdout.line! (Num.toStr)
     Err OutOfBounds -> Stdout.line! "no arg provided"
     Err VarNotFound -> Stdout.line! "variable not found"
     Err InvalidNumStr -> Stdout.line! "variable was not a number"

I'm not sure what an implementation of this code would look like using withDefault, so if you think using withDefault would result in a cleaner solution I would love to see that implementation.

Anton-4 commented 1 month ago

I've played around with this and I'm liking this type of workflow more and more :) We would however like to get rid of backpassing (<-) because we've had numerous reports that it is complex and hard to learn. I have created a variant without backpassing and I'm curious what you think!

args = Arg.list!

arg =
    List.get args 1
    |> orElseR! "No command line args provided."

envVal =
    Env.var arg
    |> orElseT! "Env var $(arg) was not set."

int =
    Str.toI64 envVal
    |> orElseR! "Env var $(arg) was not an I64 number, but was: \"$(envVal)\""

Stdout.line! (Num.toStr int)

With these helpers:

orElseR = \result, errMsg ->
    Result.mapErr result (\_ -> StrErr errMsg)
    |> Task.fromResult

orElseT = \task, errMsg ->
    Task.mapErr task (\_ -> StrErr errMsg)

If we implement this proposal, we could also write it like this:

Arg.list!
|> List.get args 1
|> orElseR! "No command line args provided."
|> Env.var
|> orElseT! "Env var was not set."
|> Str.toI64 envVal
|> orElseR! "Env var was not an I64 number."
|> Stdout.line! (Num.toStr int)

Sidenote; I'm aware orElseR and orElseT do not take function arguments but we could create variants for that as well.

lalaithion commented 1 month ago

I don't think this has the same behavior? Both of the examples still need to match on the final value to figure out if an error occurred, and therefore handle the errors far away from the place they were generated.