Rescript frustration

Especially the iterator and control flow enhancement were among the topics we actively discussed and agreed upon for improvement at last year’s retreat. I consider myself a champion for it, but I’ve had too much personal stuff to do until the beginning of this year. So… thank you for your interest and patience on that. I will do my best to restore productivity enough to dive into it.

Once a concrete proposal is ready, I will let you know and invite you to the discussion on GitHub - rescript-lang/rfcs: RFCs for changes to ReScript.

1 Like

thanks for your posts - rescript will be an absolutely amazing language with some more time to mature.

2 Likes

You’re right, but if a switch case doesn’t return the same type, you can then change its type in the branch as you did in your code actually, something you cannot do by just using ?:

| None => Error(UnrecognizedArg(`Unrecognized argument: '${name}'`))

You could also have something a bit more flexible by using polymorphic variants in the error type, then you could indeed just use an equivalent of ? with that.

Regarding Core, I think it’d be better to check the official documentation because Core got merged into the main repo and the development is done there, so you’d rather check there for the latest APIs or the docs once they’re synced (v12 is not published yet for example).

But you’re right, there’s no helper functions that expect thunks, but it should not be a big deal adding them, we’d definitely welcome a PR for this!

Thanks for sharing your code, I think there are mainly two cases, the one where you do interact with your error cases, then you do want to avoid “swallowing” errors and using early returns is not a good idea then, and the second case where you just want to short-circuit errors and in this case I think using exceptions is a good match then:

let parts = str->split
let result = parts->Array.reduce(dict{}, (acc, name) => {
  let arg =
    args
    ->Dict.get(name)
    ->OptionExt.getCustomExn(
      UnrecognizedArg(`Unrecognized argument: '${name}'`),
    )
  let value = handleArg(arg, parts)
  switch acc->Dict.get(name) {
  | Some(_) =>
    raise(TooManyOccurrences(`Argument '${name}' must only be specified once`))
  | None => {
      acc->Dict.set(name, value)
      acc
    }
  }
})

Playground link

I’d say using guards to reduce the visual complexity of deeply nested pattern matching is quite common and great experience in Rust and Effect-TS

early returns are ok with sane type system support.

2 Likes

are you recomending exceptions for program flow? i mean i get the idea here, but it might not always be exceptional situations. i guess i can just throw raise in everywhere to simulate early returns…

As I said it’s not a silver bullet, but for cases where the errors should stop the program flow and bubble up to the user anyway, like it’s the case for a CLI, I don’t really see the point of carrying results around. Plus with reanalyze, you can actually track the exceptions that are raised in your code.

Having a lazy getOrElse would be great! I would love to expand on what we have in Core, but it’s also very easy to add this to your project.

module O = {
  let getOrElse = (a, b) => a->Option.getOr(b())
}

Console.log(None->O.getOrElse(() => 42))
1 Like

I think it’d be a nice addition to Core APIs.

i’m having trouble understanding this take. this happens to be part of the cli, but it could be anything else, let’s say network stream packet parsing where you want to be able to resume anyway.

i think the “carrying results around” way-of-thinking here stems from the fact there you’re forced to carry them around in rescript.

my prior code code have been something like:

  let result = switch str->split {
  | Ok(parts) => {
      parts->Array.reverse

      let rec processArgs = (acc) => {
        let Some(name) = argv->Array.pop else return Error(NoMoreArgs)
        let Some(arg) = args->Dict.get(name) else return Error(UnrecognizedArg(`Unrecognized argument: '${name}'`)
        switch processArg(arg, argv) {
        | Ok(value) =>  {
          acc->Dict.set(name, value)
        }
        |  Error(e) => Error(e)
        }
      }

      processArgs(Dict.make())
    }

this kind of early return sanity checks, or guards, make life so, so much easier, and you don’t end up with either a huge, hard-to-read switch statement, or ridiculous nesting.

I think result handling is definitely an area I think can be improved, but when faced with something like this I’d just use exceptions and hide it away in some kind of util function that does the throwing for me

let result = str->Argv.process((name, acc) => 
  switch name {
  | "a" => getA(acc)
  | "b" => getB(acc)
  }
})

I use a json decoding lib that does it this way and quite like it, and handling multiple results is usually done in one place at the bounds

1 Like

I totally understand the appeal for sanity checks, guards, etc.

What I’m saying is that you should generally aim at having the least complex type possible, so if you only care about the presence or absence of a value and not about the error, then use an option<'a> instead of a result<'a, 'error>, but if your code can only work with the presence of this value, then just use 'a and raise an exception when it’s not there. Otherwise, what’s the point of carrying around the error if you never actually read it?

To me, results are great when you do want to take into consideration both ok and error cases, by using switch you get exhaustiveness checks (I try to pattern match every case of the error variant for example), this way, the day you add a new error case, you get helped by the compiler. If you start using results and just throw the error information at the beginning of each function with sanity checks, you lose this exhaustiveness check and don’t really use your result type. I know I got bitten by this quite a few times using let* in ocaml.

It makes you focus on the happy path, if it’s used only in a few places it’s ok, but if you never care about the error cases, it can become a smell.

One more time, I’m not saying this makes early returns useless but it should make you think about how pervasive and automatic their usage should be. And a good language should take this into consideration.

2 Likes

help me understand this. the point of carrying around errors is you can handle them at the boundary and list them all, print them out with sane error messages instead of crashing hard. there might also be the case of having mutated states in files or databases that need to be handled differently depending on the error.

we’re making a distinction BECAUSE rescript is lacking certain monadic features. early returns, or the ? operator, would mitigate this, and we would be discussing a distinction without a difference.

I’m not talking about crashing hard, you can fortunately catch exceptions and list and print out the sane error messages exactly at the same place as if you had used result:

switch processRequest() {
| Error(UnrecognizedArg({argName})) =>
  Console.error(`Unrecognized argument: '${argName}'`)
  Process.exit(2)
| Error(TooManyOccurrences({argName})) =>
  Console.error(`Argument '${argName}' must only be specified once`)
  Process.exit(3)
| Ok(result) => Console.log(`success: \n${result}`)
}

vs

switch processRequest() {
| exception CliExn(error) =>
  switch error {
  | UnrecognizedArg({argName}) =>
    Console.error(`Unrecognized argument: '${argName}'`)
    exit(2)
  | TooManyOccurrences({argName}) =>
    Console.error(`Argument '${argName}' must only be specified once`)
    exit(3)
  }
| result => Console.log(`success: \n${result}`)
}

Playground link

The distinction does not come from the lack of monadic features but from the semantics and intended usage: will I process the error on the boundary of my program only or will I reuse the error somewhere else in my program flow?

If you use the same type for different usage, you’re likely going to misuse it, typically in this case by ignoring error cases that should have been checked before the boundary if you take the habit of unwrapping every result at the beginning of each function.

But sometimes, even though you know you need the error cases in different places of your program, there are functions where you only care about the ok case and there unwrapping results shines, I totally agree.

Carrying around Results or Options can feel odd at first, but once you understand the types it’s hard to not use them.

This is a great talk about how to use ADTs to contain data and pass it along in a program.

1 Like

i’ve actualy seen that particular video before. no matter, i’m not sure who you’re talking to - i’m the guy promoting carrying results and options around in this discussion! :>

@tsnobip help me understand more what you mean. why shouldn’t i use exceptions as results(errors)? why is it bad if i don’t need to move the errors around my program code?

also, on the contrary, if we did have early returns, why would i prefer to use exceptions (except panics) over results?

Not really responding directly to anyone in particular, I just wanted to add some information to the thread for anyone who comes across it.

I’ve been meaning to write up a blog post on using Railway Oriented Programming in ReScript. I did one for Typescript many years ago.

3 Likes

I may not have expressed myself clearly, I fully support the use of results and options. In fact, I use them all the time and find them highly beneficial.

I’m just saying that if there are some results you use only to short-circuit to their ok value and check the error cases only on the boundaries, it’s a smell you might be better off using a more direct style and use exceptions instead of result for these values. Especially given their exceptions can be statically tracked in rescript with reanalyze. In my experience, those cases are the most common use cases for ? or equivalent operators.

Using early returns instead of pattern matching with exhaustiveness check can reduce the help you get from the compiler. When you add a case to your error variant, the early return would just swallow it while the compiler would force you to handle it with pattern matching.

Using a more direct programming style and simpler types also make error messages easier to read for example, code easier to grasp, etc.

I know that when dealing with code that uses “advanced” features like complex monadic infix or let* operators, I need much more time to get familiar with it, so I’d rather use advanced concepts as little as possible.

2 Likes

I am a fan of figuring out how to enable more complex FP patterns, but we should make sure that they aren’t the norm and we scare away JS devs. We want JS devs to like ReScript, but we should also work to gain traction from devs who use Rust, Elm, etc… and offer ReScript as an alternative to TypeScript for devs who want a more sane type system and features they like.

1 Like

can you elaborate on this further? help me see how you make the distinction here. can you give examples?

experimenting for a bit and the only conclusion i can sort of draw in the end is that exceptions are interesting ONLY because I cant do early returns. i dont understand why you’re saying the compiler cant be as helpful with early returns. if you do early returns with e.g. the ? operator, you’re moving exhaustiveness checks to the caller. if you dont want to do that, you can still switch.

also since we don’t even have early returns, i can only do certain loops as recursion anyways, so i might as well recurse on results. i hate to say it but i feel rescript has picked the “worst of both worlds” approach here. :<