Should ReScript move more towards explicit control flow?

TypeScript doesn’t solve JavaScript’s problem with implicit control flow. For example, any library function (or really any function) might throw an error or do something unexpected. Unless you wrap it in a try/catch, your application can crash unexpectedly.

ReScript has the result type, which is great for explicit, recoverable error handling. However, I notice that throwing exceptions is still very common and feels like a first-class concept in the language. Functions like Option.getOrThrow, List.headOrThrow, JSON.parseOrThrow, and similar helpers are provided in the standard library. They make it easy (and tempting) to throw in many situations.

One of ReScript’s biggest strengths could be creating safer libraries by porting TypeScript or JavaScript ones and replacing implicit throws with explicit option or result returns. Speaking from experience, ReScripters will likely reach for these methods that throw to reach parity in their codebase, and may even stop there. However, deprecating or eliminating these functions would force consumers to handle errors properly instead of letting crashes happen silently or loudly.

I’m wondering whether the ReScript type system and standard library should lean even more toward explicit control flow. Perhaps we could do this by deprecating, renaming, or marking as “unsafe” the methods that throw exceptions in favor of their safe counterparts.

What are your thoughts? Is this already the direction the community wants, or are exceptions (and throwing helpers) intentionally kept for pragmatic reasons, especially around JS interop?

1 Like

We’ve discussed this at times, but there’s no clear consensus in that pursuing something that enforces that specific style of programming makes sense for ReScript.

However, there are plenty of things we could do to support that use case and make it easier to adopt it fully if you wanted to.

  1. Reanalyze can already do some exception analysis (warn whenever you have code that might throw, that’s not handled properly). This could be extended, polished and made easy to use as 1st class enforcing of “no unhandled exceptions”.
  2. There has been very loose talks about providing something first class that can automatically wrap bindings code into something that catches exceptions and produces a result instead. Pseudo example: @module("some-parser") @toResult external parse: string => result<parseResult, string> = "parse" where parse from some-parser would be a function that can throw.

I doubt we’ll want to be as prescriptive as enforcing one style over the other, and we’ve made a conscious decision in the new stdlib to be close to JS (with some minor deviations). But supporting going that route if you want to is definitely interesting, at least to me.

5 Likes

I would be in favour of #1.

1 Like

1 and 2 are different problems although there’s some overlap in general.

1 is about rescript code, 2 is about externals. there’s no clear “safe” way to do externals right now (think try in moonbit or zig). there’s not even any way to clearly know whether an external throws, jsland is so content with exceptions being thrown that they’re sometimes not even documented. but i’d be in favor of something like

let Ok(foo) = try? bar() which turns foo into a Result<'a, Exn.t> or similar. togeher with it, i’d also like

let foo = try? bar() else { "abc" } where foo can only ever be of type 'a. i’m probably missing a few cases here and it would probably also tie into early returns somehow, and it could probably be done with a modified let? and so on. but this needs to be explored more in rescript.

I think it should move towards being more explicit, especially as we build out the web APIs. I think you have to have getUnsafe to make things easier sometimes when you know what you are doing, and hopefully rewatch can help flag these unhandled exceptions.

If i use fetch I would like for it to return a Result<unknown, FetchError>, and have FetchError be a variant type for the different type errors fetch can throw.

This would be amazing:

let? Ok(response) = await fetch("api/data")
let? Ok(json) = await response->Response.json
let? Ok(data) = json->parseData

No try / catch or .then or .catch but full error handling. It would mean that internal bindings would have to have some amount of runtime code, but we’re all going to write that anyway when using things like fetch. I see no reason to have that burden exist every time I want to use fetch and it would be preferable to have it baked into the official bindings.

What you’re describing is really quite easy to do in user land already:

module Response = {
  type t

  @send external json: t => promise<JSON.t> = "json"
}

type data = {
  user: {
    id: string,
  },
}

external parseData: JSON.t => result<data, [> #ParseError]> = "parseData"

let toResult: promise<'v> => promise<
  result<'v, [> #Exception(exn)]>,
> = async p => {
  switch await p {
  | exception e => Error(#Exception(e))
  | r => Ok(r)
  }
}

external fetch: string => promise<Response.t> = "fetch"

let main = async () => {
  let? Ok(response) = await fetch("api/data")->toResult
  let? Ok(json) = await response->Response.json->toResult
  let? Ok(data) = json->parseData
  Ok(data)
}

It compiles to a try/catch with await, so it caches promise errors.

But I guess the toResult function could be a good candidate for the stdlib indeed, even if it’s simple.

3 Likes

Yeah, it’s very doable and I do it all the time, but it would be nice to just have Response.json, fetch, and other web APIs with possible failures just return a Result type.