Idiomatic way to "propagate" errors

In Rust, we have early returns and the error-operator (myFunc()?) which returns early if the result of applying the function is None or Err.

What’s the Rescript way of doing the same thing? This situation always has me reach for raise because I don’t know how else to do it without ending up with a large amount of nested switches.

By the way. Why not add the opt-in return keyword to ReScript? Similar to how assert works now + type check for function return type. Moonbit recently added early return, and I think it’s a sweet feature.

2 Likes

yeah I would REALLY like to have the return keyword - I think it would just add to the language. many functions are way more easy to reason about with a couple of early return guards.

In case you want to chain the outcome of several operations which could each fail, I’d say you probably want to use the result type and then use Result.flatMap.

Derived example

let calcA: unit => result<int, [> #undefinedBehaviour]> = () => {
  Ok(42)
}

let calcB: int => result<int, [> #zeroNotAllowed]> = i => {
  if i == 0 {
    Error(#zeroNotAllowed)
  } else {
    Ok(42 / i)
  }
}

let f: unit => result<int, [#undefinedBehaviour | #zeroNotAllowed]> = () => {
  calcA()->Result.flatMap(calcB)
}

let main = () => {
  switch f() {
  | Ok(i) => Console.log(i)
  | Error(#undefinedBehaviour) => Console.error("unknown error")
  | Error(#zeroNotAllowed) => Console.error("ivision by zero")
  }
}

In this example, I demonstrate the pattern of using polymorphic variants for errors, because they compose easily. - but come with their own drawbacks

If you don’t care about the reason of failure, just use the option type instead.

If you want to express an xor relation, use if/else.
In my personal opinion it’s at least as readable as early return, while it feels a bit more explicit.

2 Likes

I’m result and option fan but what I found so far is that it forces a user to wrap things in a switch to pattern match which can lead to several nested switches and that’s ugly.

For example with case like this:

let test = async (): result<unit, unit> => {
	let aResult = await doSomething()
	switch aResult {
  		| Ok(d) => {
          let aRestul2 = await d()
          switch aRestul2 {
           	| Some(value) => Ok(()) //do something
			| None => Error(()) //could return earlier
          }
      	}
		| Error(_) => Error(())// could return ealier
	}
}

I found it super common to do async stuff after destroying the result, and Result.flatMap won’t do alone in a case like this.
It’s probably possible to mix Promise.then with Result.flatMap but it’s quite a complex task. Is there any better way?

1 Like

For async results I built a library. So map and flatmap will process successful resolved promises.

You can check it out here: GitHub - dkirchhof/rescript-async-result

(Sorry, not published to npm and no readme yet)

6 Likes

I would still prefer some kind of let ops / computation expressions / … But I don’t think it will be implemented in rescript

1 Like

Have you thought about making an issue in the Core repo and sending your work on rescript-async-result as a PR?

I can remember discussions in F# forums why they don’t want to bring such functions into core.

But this is rescript… Maybe @zth @cknitt @glennsl or @aspeddro have their own opinion about it :slight_smile:

1 Like

Not the cleanest switch I’ve ever written, and def not rescript kosher, but this is a bit more readable

let test = async (): result<unit, unit> => {
  switch await doSomething() {
  | Ok(d) if Option.isSome(await d()) => Ok(())
  | _ => Error()
  }
}

(ReScript Playground)

Somehow this feels like .then().catch() with extra steps :sweat_smile: Especially since async calls are pretty likely to be calls to outside world (API, 3rd-party libs and whatnot), so they could probably throw, so you should probably handle it anyway.

When it comes to async stuff, I think it’s useful to differentiate between exceptions and errors. Network, disk, and db errors are unexpected and you should just let get thrown, since at the end of the day the callee is probably just displaying it to the user somehow. Just treat all promises as dangerous and handle it at the top of your stack.

Results are better used for expected errors like validation or enforcing your domain’s rules where you can Result.flatMap to chain multiple together

DB errors are as unexpected as validation, parsing etc. IMO separation on critical (should terminate) and non critical (should pass back in some way to user) is much better.
Also, Results are far more elegant then unexpected error thrown somewhere in code.
In mentioned Rust wrapping Result with Future is absolutely fine.
I played a lot with Rust Futures and Results, flat mapping, pattern matching and so on… And it’s cool and fancy, but thanks to early return all these extra brackets around pattern matching and all these methods to deal with Results and Futures are unnecessary and code is leaner and easier to read (and often requires a lot of casting from one error to another with Into or From traits).

Rescript is far less verbose then Rust.
I remember when async was not part of language and I was piping and mapping futures, and now we have async/await and code is simpler.
Maybe one day it can be simpler with early return but now we can be fancy with flat mapping :smile:

I often hear that you should differentiate between exceptions and errors, but I don’t understand why I should do that.

Example:
A user wants to create an account. He / she submits a form with email, pw1 and pw2.
What can go wrong?

  • Some missing or invalid params (client side validation)
  • Same validation on the server (server side validation)
  • There is already an account with this email (server side validation)
  • The server is unreachable (client side exception)
  • The server can’t connect to the db (server side exception)

I can try catch on the server and return 5xx status codes, I can use the result type and return 4xx status codes, I can try catch on the client and show some notifications and I can use results to show validation errors in the form.

By converting exceptions to results, I only need a single switch case at the end of the chain.
Another advantage: I have seen so many TypeScript projects where risky functions aren’t wrapped inside try catch blocks. The result type has to be handled.

Well, the only reason I would keep exceptions are the stack traces, but I can live without them.

Just my thoughts. I would be happy to hear counterarguments.