[RFC] New Promise Binding

I think that it really isn’t worthy to infinitely bikeshed naming back and forth and there’s precedence for having map and then. If anything I think then and flatThen would just confuse newcomers as much as people already used to FP

Now into the actual problem I see with this proposal: footguns in standard library; I really don’t think that the standard library should have the two unsafe patterns in probably one of the most used types in common JS after arrays and objects. Standard library should at the very least strive for full safety even if the cost is a small runtime check. If 0 runtime is a hard requirement for someone then we can offer an alternative UnsafePromise type that should be used only if the person absolutely knows what they’re doing

5 Likes

I strongly prefer map and flatMap. I think flatThen is way more confusing than flatMap. It’s the first time I’ve ever seen flatThen, but I guess I could be biased.

I don’t think map and flatMap are problematic at all. In fact, it’s a pretty normal convention in the rxjs library, so anyone coming from Angular would probably pick it up quickly.

If we have good documentation on promises and async programming, then it should be simple enough for anyone to pick up.

6 Likes

Nice work overall!
At the minimum if then is chosen, I’d like to have a flatMap alias, since it’s not ambiguous as then for those who prefer.

6 Likes

Since this whole map / flatMap / then discussion is apparently the biggest bikeshed here, I am actually considering removing map and just make it a pure reflection of the JS Promise API instead to prevent any kind of confusion for anyone.

Ultimately this is not supposed to be a FP inspired API, so there’s no question ā€œif we should expose thenā€, because this is a hard requirement for following the general zero-cost binding philosophy.

Settling on the most minimalistic approach would probably make things way easier, because this would result in a 1 to 1 translation for the current Js.Promise bindings from pipe-last to pipe-first (with improved error handling).

It’s a shame to see the map function go, but on the other hand, it’s easier to add things later on in the future.

ReScript v9 just shipped, so we now can simplify the api even further by replacing Promise.JsError with the builtin Js.Exn.Error instead.

(honestly, there’s not much left that’s there to ā€œproposeā€ in this proposal, so I guess we can speed up the process replacing the original Js.Promise soon)

9 Likes

+1 for this

Maybe we can have another abstraction in belt? which includes map flatmap keep etc.

we can also tweak the implementation a bit in belt since js promises are not well designed.

what about introducing something like Belt.Future?

3 Likes

It is not planned to add any new functionality to Belt anytime soon. There are some important language related decisions that need to be made first before we can start cleaning up all the Js / Belt modules. Until then, user space libraries are the way to go (which is not bad actually, considering how hard it is making api decisions in core libs).

3 Likes

rescript-promise@1.0 is now released on npm.

See the changelog for details: https://github.com/ryyppy/rescript-promise/blob/master/Changelog.md

map has now been removed from the API

5 Likes

I want to reply briefly to what appear to be some misconceptions, and also make some arguments against the current proposal. Some of this is in the spirit of @yawaramin’s reply about misconceptions about Prometo.

  1. reason-promise does not ā€œtrackā€ rejections — that’s only for the low-level bindings API. The main API has a dead simple type promise('a), rejections are impossible, and nesting results in it is fully optional. You can use any other error handling strategy on top of reason-promise.
  2. If I could hide the abbreviation that promise('a) is actually rejectable('a, never), I would — perhaps it’s an argument for some kind annotation so that libraries can hint to the compiler not to show the expanded type. Now that I think of it, there might be a way with a double-underscored internal module.
  3. reason-promise is not a layer over Js.Promise, but a direct binding to JS with its own small (<1K) layer for fixing type safety issues with JS promises and defining all its helpers. The runtime is spiritually the same as what option has for unboxed options. reason-promise could be made trivially compatible with JS promises by setting promise('a) = Js.Promise('a). Then, as long as you choose not to use any of the Js.Promise functions, you can use Js.Promise objects with reason-promise functions in a type-safe way. The reason (eh) I chose not to declare the types equal, is so that the type system tells users to handle rejections in bindings, and you can be sure that untyped rejections don’t trivially leak into your program. The types are equal in the implementation, however. Only the functions are different.

@ryyppy, this almost weakly implies that reason-promise and/or Prometo have some kind of non-trivial cost. Speaking at least for reason-promise, it has benchmarks, which show that the cost is effectively zero. The actual run-time work amounts to an if-check, which causes no further work in the vast majority of cases. In exactly the one case where there is more work, the case of nested promises:

  • reason-promise causes one extra allocation to avoid any type problems.
  • naive promise bindings die — the resulting objects in the future of the program will have values that don’t match their types.

Given the concern with real-world issue reports, are there any real-world issue reports stating that the cost of an occasional extra if is significant next to the cost of the I/O operations that are performed around promises to begin with? If not, why the goal of zero-cost bindings, rather than extremely-trivial-cost bindings, especially in exchange for type safety? And especially if the cost is the same as the cost of the option boxing check?


I understand the proposed binding has changed somewhat since the beginning of this thread, and the goal has been changed and/or clarified.

I personally would still argue for the basic binding to be something type-safe, that is, a small runtime like reason-promise (like option). I would omit the result layer from it, and leave it for separate libraries.

The reason I would argue for this is because Js.Promise and similar naive bindings are not type-safe in at least two ways:

  1. Nested promises break the types, and, yes, they will eventually occur (I routinely forget which of my functions return promises and which don’t, especially when I am constantly refactoring where I/O operations occur).
  2. Untyped rejections are untyped. Doing them this way amounts to pushing bindings work away from the bindings author and towards whoever ends up handling the rejection in their app code.

Given that a major attraction of ReScript is the type system and type safety, it seems odd that such a basic I/O construct as promises breaks exactly type safety.

The tradeoff, of course, is that bindings are more difficult to write with reason-promise-style promises, in particular when libraries return rejectable promises. However, that is the friction arising from the differences between ReScript and JS and their respective type systems or lack of such, the cost of adapting JS to ReScript, and not pushing the rejection onto the end user. There are already difficulties in writing bindings when JS functions are overloaded, return strange combinations of values, etc. reason-promise style rejection handling adds to the work, but it’s the same kind of work and for the same purpose.

Speaking of this untyped rejection, it is not comparable to Java’s unchecked exceptions. Unchecked exceptions are still exceptions, and can be handled with catch, etc. Unchecked exceptions are what OCaml has in type exn. Untyped JS rejections can be any value at all. They are really a sign of an incomplete binding that has mapped values but not errors into ReScript. An unchecked exception in reason-promise speak would be a rejectable('a, exn), which, coincidentally, is also what Lwt.t('a) is. So there is considerable precedent for Java-like unchecked exception-style programming in OCaml (well, all of it is), and it’s not broken — but it’s not Js.Promise, which is broken.

Rejections (and exceptions) really need more research. You could look into more detail at Async — have you? Not sure if there is anything that can be borrowed there.

19 Likes

It’s fair to say that the initial discussion in the thread has shifted towards a simple replacement for Js.Promise, rather than something with the goals of more sophisticated libraries.

For type safety/boxing tradeoffs, something that is perhaps not explianed well is zero cost: it also means the generated code is identical to what you’d write by hand. So when eg debugging you don’t need to go look into some runtime lib. The difference w.r.t. optionals is that with optionals, in practice, the special runtime is rarely invoked in generated code (only when the type checker can’t figure it out). We might get there in the future, but we’re not there yet for promises. Also, there are some checks one could experiment with, that prevent you from creating nested promises statically.

5 Likes

An interesting thing I noticed while writing something today is the existence of both ceil_int and unsafe_ceil_int in std, where one of them has a tiny runtime. I think what a lot of people want is to at least mark the new Promise bindings as Unsafe, because well that’s what they are. We can then work on deciding whether including a safe variant in std is something worth tackling or if we should recommend using 3rd party promise libraries.
I personally favour safety and soundness (why I am picking ReScript in first place over alternatives) over tiny runtime hits

And also std should strive to be consistent, correct? If a type or function is unsafe and must be used with care (for instance unsafe_ceil_int) it’s marked as such. If we’re including new unsafe promise bindings as part of std eventually then the consistent thing will to be mark them as such, right?

3 Likes

Tried this out today, converting from Js.Promise.t usage; output is pretty much the same with the exception of catch being imported, which seems expected. We generally aren’t using promises yet in our ReScript code but are instead importing the API functions into TypeScript, so it was nice to preserve the ā€œthinā€ binding to Promise.

One tangential issue I ran into was that gentype didn’t pick up on the ā€œtrueā€ type being Promise, it instead tried to use this:

import {t as Promise_t} from '@ryyppy/rescript-promise/src/Promise.gen';

Manually typing return values as Js.Promise.t works as you’d expect, and I’d assume it’ll be fine when it gets put into the rescript-compiler runtime?

@ryyppy I read through your RFC and the comments here and I have to agree with your conclusions. I think the job of the default library in Reason is to bind to JS closely in a (reasonably) type safe way.

This allows others to build on top of it in whatever way they see fit. We for instance actually bind to Bluebird which has fancy mechanisms for propagating stack traces and I will say that you introduce another ReasonML wrapper you’re making stack traces worse.

Yes we want to prevent runtime exceptions but defending yourself against every JS lib you wrap is a full-time job all on its own, it’s going to happen any sizable app I would argue (we’ve been running Reason in production for years now on the server mostly).

I fully agree with the arguments that ReasonML beginners are in a very fragile state usually, small things such as readable output and familiar APIs will make or break trying to sell it to a technical lead at your company.

2 Likes

Is there any update as to when this might make it into the rescript package itself? Assuming that’s eventually the idea.

There are plans on shipping it in the next few weeks, but probably in a different way than originally planned. We want to take a chance to gracefully improve the Js namespace situation, that’s why it will take a little longer than expected.

7 Likes

Excellent, looking forward to it! I’m planning on transitioning a few things to it, but I’d rather wait until it’s in the package itself, so :+1: that it’s hopefully available within a few weeks.

2 Likes

No plan to add map and flatMap to the API at least as alias to thenResolve and then? I know this is not inside Belt’s namespace but inside Js’ where the goal is to keep as close as possible to JS names, but still would be nice to have the choice so you can keep coherent names in your code base.

@tsnobip no plans on doing that, but should be relatively easy to extend the provided Promise module with your own aliases in your app code. Library maintainers need to stick with our then / thenResolve conventions to stay lean.

Also let’s wait for the upcoming async story plans before getting back into this bikeshed again :smiley: :crossed_fingers:

6 Likes

Yeah really looking forward to that, to me it’s the last pain point remaining in rescript projects, so much has been done already to make development in rescript an amazing experience!

2 Likes

Excited that this is shipping soon! Looking forward! :eyes:

I have a question about Promise binding.

Because it seems most libraries didn’t discuss about Promise.allSettled the new binding would be a good place to start.

The result of executing allSettled is Fulfilled('value) | Rejected(reason)` variants and I don’t think a zero-cost abstraction is possible for this.

Does this mean that official binding can intentionally silentt on some standard functions and leave them to user-level abstractions?