Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
lalaithion opened this issue May 19, 2024 · 4 comments

Comments

@lalaithion
Copy link

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
Copy link
Contributor

Anton-4 commented May 20, 2024

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
Copy link
Author

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
Copy link
Contributor

Anton-4 commented May 21, 2024

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
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants