[RFC] New Promise Binding

I love this proposal, thanks! I think it’s great to keep it simple and if you have more complicated use cases, there are other libraries. We don’t really need more than this for a medium sized app, so I suspect this should be fine for a lot of users!

I was a little confused on the difference between map and then (because in JS then can be both). I suspect flatmap will make it easier to understand even if it’s not the same naming as JS.

1 Like

I am probably in the minority here as I have minimal FP knowledge. I had the opposite experience to some others in the discussion so far.

When I read the docs:

  • then is being used to provide a callbacks that returns another promise
  • map is being used to provide a callback that transforms a value within a Promise chain.

Along with the examples, this was clear to me. But I now understand why it might be confusing to some.

Clearly a lot of thought has gone into the current proposal and I assume a principal being followed is that when binding to JS APIs we stay as close to those APIs as possible, even if it’s at the expense of using correct FP terminology?

Very few of the JS devs I’ve worked have had any FP knowledge. If I was trying to sell ReScript into these teams then familiar terminology is a significant selling point. In fact promises with then and catch are so familiar than any deviation from that would be hard to explain.

To be honest, I didn’t understand why flatMap was being proposed as an alternative to then. I’m familiar with flatMap for the JS arrays, but I was unclear how that related to promises. I’ve since done a bit of research and I think I now understand how the types for then are actually flatMap?

Just wanted to share a perspective that I feel has not been represented much in the discussion yet. Hope it’s helpful.

6 Likes

Great work, I think the new bindings look very good. I’d probably advocate for an alias of then as chain and flatMap. The name chain is more idiomatic in the JS world and used by ramda, fluture, and Fantasy Land, which is a spec for JavaScript FP libraries. There are quite a few packages which follow this spec, and many looking to do FP in JavaScript reference libraries here which implement the Fantasy Land spec.

I don’t think a deprecation warning is necessary to make one more preferable than the other. In terms of documentation though, I would use then in code examples as that’s going to be better understood by a wider JS audience.

If this is going to a zero cost abstraction the I see why you’d want to raise and catch, but ultimately I’d prefer to see a Belt.Promise that uses Result as I believe that would be more idiomatic in ReScript.

In any case, good stuff and thanks for working on this.

1 Like

I don’t think multiplying the aliases is a good idea though. Belt uses the flatMap convention already, so I think all ReScript libs should try to use that. Not chain, not bind :man_shrugging: Let’s try to converge, not diverge :pray:

11 Likes

I was thinking about also using then and flatThen as aliases for map and flatMap. (So then would be the opposite of the current binding’s then.)

Could be more intuitive, or maybe not :man_shrugging:.

Although, now that I’ve tried out the proposed binding, the difference between then and map seems easy to get used to in practice. Since I’m already aware that promises are quirky anyway, I don’t really mind that they look slightly different than other monadic types.

I said technically it’s a flatMap, but actually it’s both, a map and flatMap, which doesn’t fit into our static type system and makes it rather special. That’s why I originally experimented with an api that was a little bit unconventional: then for transforming a nested value, and flatThen for chaining promises. After getting some feedback from other users, I switched that back to the design we have right now.

I recently got some more feedback from other community members that prefer the then api as well, but aren’t that vocal on this forum (some are production users using ReScript in frontend / node and their own custom promise bindings; some are interested JS newcomers that joined my live coding stream last week, etc.).

So my final thoughts on why I think we should settle on then:

Async operations are probably one of the first things newcomers are going to attempt when e.g. trying out rescript-react, otherwise their UI will be rather useless. If this one flatMap will cause confusion for just one user, then it is very likely that there will be a hundred / thousand more that will fall into the same trap. Non-ReScripters that haven’t even used ReScript, will have this extra mental mapping while reading getting started blog posts, or when reading their first ReScript code a coworker introduced for assessing the technology. Even if it doesn’t sound like much, a flatMap magically turning into a then in the JS output is non trivial mental mapping. I know that I am kinda contradict myself here because I added a map function, but at least map is a little bit more familiar due to way more common array map usage. Hopefully the first thing ppl will try is Promise.then anyways.

Regarding Belt: It is not the very first API users are going to use, they will most likely be using Js.Array2 / Js.String2 etc. first, because it’s closer to the JS apis for the beginning. When they are ready, they will be using Belt for more advanced stuff presumably. From a user-journeys view, first we need to get ppl use the technology without much pain by introducing familiar apis. Later on, when they are committed, it’s way easier doing the extra work teaching about ReScript specific conventions.

The team’s opinion right now is trying to stick to following mantra: when in doubt, stick with JS conventions / names, especially when we talk about zero-cost bindings.

Now, I hear your complaints on “why are we always focusing on the JS newcomers”, and the reason is very simple: Because if an API is simple enough that a complete newcomer will understand, then chances are very high that a more advanced users will understand it as well. Sure we could teach them some ReScript conventions during the onboarding, but who’s got time for that? It’s a death by a thousand paper cuts.

From previous API design experiences, I want everyone to think back to a time where reason-react utilised a record-based component api. Can you remember how unintuitive the API looks in comparison to today, even though it was not that complex to learn? Ever since the React bindings follow the 1-to-1 name mapping approach, it unlocked incredible potential on the teaching and interoperability front. Not being forced to constantly map back and forth between JS / ReScript naming and types is an incredible feature a lot of ppl take for granted.

8 Likes

Belt uses the flatMap convention already, so I think all ReScript libs should try to use that

Ah yes, I wasn’t thinking about that. Makes sense - although sometimes I wonder if the Js module should follow JavaScript idioms and Belt follow OCaml/ReScript idioms, given that its more opinionated.

One downside to flatMap and chain is that I would expect the inner implementation to use join and map internally, which obviously isn’t possible for promises.

Anyhow, I don’t have a strong enough opinion to push much on this. I’m fine with using then, but in my head, I’ll be thinking chain. If we have a Belt.Promise module though, I would would hope to see flatMap there for the reason you listed above - to keep consistent with other Belt modules.

Well, you’re right, these are standard API bindings, and they could have their own conventions.

An obvious problem is that you use a JS convention and call a function then, you still need another function, no matter how you choose to call it, and I bet this will still trip up people coming from JS (“Why do I get this type error? This was fine in TypeScript”).

As an aside, I’m not sure I fully buy the “let’s be as familiar to the JS crowd as humanly possible” argument. For one thing, there are bigger WTF moments for newcomers than Promise.map and Promise.<that_other_fn>. For example, modules like Belt.Foo2, or rather their obsolete t-last siblings :slight_smile:

More importantly, people can learn, and in the long run, what matters for keeping the mental overhead low is consistency. If everything is t-first and uses names like flatMap and forEach (personally, I’d prefer bind and iter, but it’s not important), you have more capacity to think of the essential complexity.

3 Likes

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