When helpful becomes hurtful - some areas where ReScript is problematic

I would assume almost all of us here agree that more type safety is better. Static code analysis, handling edge cases and having the compiler tell you “hey, you didn’t handle this particular case…” is generally a good thing. For most of my code, I feel ReScript is helping reduce the amount of bugs I would otherwise introduce into the code. Although I consider myself rather smart, and although I’ve been coding for a quarter of a century, I make so many mistakes. Thanks, ReScript.

But there are times when ReScript no longer wants to be my friend - times when it becomes a tyrannical boss instead. Although ReScript is coherent and logical - in the end, I am smarter and more experienced. And perhaps most importantly, I know better than ReScript when it’s time to be pragmatic about things.

The doc makes a silly example of this:

// Dom access can actually fail. ReScript
// is really explicit about handling edge cases!
switch(ReactDOM.querySelector("#root")){
| Some(root) => ReactDOM.render(<div> {React.string("Hello Andrea")} </div>, root)
| None => () // do nothing
}

From: Rendering Elements | React

To me, ReScript being so explicit about handling edge cases as in the example provided above is not helpful. First of all, I have never, ever, ever had the ReactDOM.querySelector call fail.

Actually, that’s a lie. It has happened. I’ve named the root element incorrectly, or forgot to put it in there. So maybe ReScript is helpful here, after all?

No way!. If the querySelector call fails and returns nothing, nothing in my app is going to work, it’s not even going to render. Ask yourself which is more helpful, a blank screen or everything blowing up with error messages? Ten times out of ten, I want things to crash and burn with error messages if the entire app is unable to function. ReScript forcing you to do simple “hacks” to get it to shut up is causing you to write bad solutions here, introducing problems that will take more of your time. Let’s say you open your app, and it “does nothing.” Your browser shows a blank screen. Where do you even begin to debug your shit to finally realize that you had named the root element incorrectly? Good luck with that - that can potentially be an entire day’s worth of work out the window. If ReScript instead would let things blow up, I could fix it within minutes. I don’t think I’m exaggerating here.

Hey, this does not mean ReScript is bad. It’s great - I love it. But there needs to be a lower threshold for escape-hatching. I think we should reason about escape-hatching as were it an analog scale, from zero where everything is without types (hello, JavaScript) and “forcibly typed” where there aren’t any escape hatches.

A default typeless setting is obviously a bad idea. We’ve tried that, it makes your code full of bugs (hello, Javascript).

I would argue the opposite is as bad. It forces you into idiotic solutions as the one above. “oh I need to handle this case that might happen one time out of a million, ah crap, that’s never gonna happen, let me just put a no-op on it”. How is that better?

I think what I’m trying to communicate is there might be a need to “lower the threshold” for escape-hatching a bit. I think it’s a matter of finding the sweetspot where it’s tedious enough that you don’t do it as soon as you encounter a situation as the one above, but where it’s simple enough that you don’t put no-ops just to get the compiler to work with you.

Here’s another, less serious example:

...

      {if isAdmin {
        <ul> {adminItems->renderSidebarItems} </ul>
      } else {
        <> </>
      }}

...

Putting the else part there is tedious and annoying. JSX is already doing all sorts of hocus pocus behind the scenes, it might as well do it for cases like that as well. It makes my code more neat, shorter, makes me have to type less and in no way introduces any more errors.

I think there is a certain level of pragmatism required here, and we should probably spend some more time thinking about it. There’s generally a fear of ad-hoc’ing solutions in the compiler for known cases, but maybe that would be the right thing to do. Yeah, ReScript would become a bit warty - but it already is in other ways and we might as well look to develop an environment that is helpful as much as it can be rather than being a software implementation of Putin.

Give me your thoughts on this.

1 Like

You can actually raise an exception in the None clause of the ReactDOM.querySelector("#root") matching and make it “blow up” with an error message.

I think making things explicit is the price you pay for using a (ML-style) statically typed language, IMHO.
What if you accidentally omitted something and the compiler assumes you know what you’re doing? It is safer to be conservative make sound decisions.

7 Likes

When there’s an impossible case, you should throw an exception. For example use Belt.Option.getExn in code with selector, or explicitly call Js.Exn.raiseError. Then you can catch these exceptions in an error boundary and send them to Sentry.

You can read about:

  • Exception flow vs error flow
  • Fail fast
5 Likes

Or even use the super-convenient built-in function failwith, e.g. failwith("DOM content root is missing").

4 Likes

Thank you, I missed this one. Interesting.

So my journey so far:

First real problem was bindings. It was easy after a couple of days - once I learned to think about types as being nominal instead of structural.

Second was promises. I installed the 10.1 alpha with async/await so that is fine now. To be honest though, I had gotten so used to promises so it doesn’t make much of a difference. I’m unsure whether I prefer async/await now. Doing chains of promises and results is probably neater.

Third is interop’ing with JS polymorphism madness. I’m unsure whether I will get accustomed to this. It’s not uncommon for JS APIs to just throw random garbage at you. It works most of the time, but yeah… when type safety is enforced, becomes tedious.

3 Likes

Took me a minute but I’ve kinda grown into liking functions that have one set of arguments, and creating different ones instead of overloading one function. It’s something I’ve even taken back to my TS codebases.

Keep in mind for complex functions you can always declare multiple bindings for the same function

external addString: (t, string) => t = "add"
external addInt: (t, int) => t = "add"
4 Likes