aantron / promise

Light and type-safe binding to JS promises
MIT License
340 stars 24 forks source link

Support let.await once Reason is updated in BuckleScript #52

Open aantron opened 4 years ago

aantron commented 4 years ago

See https://github.com/facebook/reason/pull/2487.

aantron commented 4 years ago

.await is the obvious choice for JS familiarity. For some reason, the term never grew on me. I always liked .async, even though in JS it is used on the "other side." If anyone has ideas about what the word should be and why, please say :)

jordwalke commented 4 years ago

How does the bind/map stuff come into play with a Reason async monadic API? Does one have to do:

() => {
  let.async x = foo();
  let.async y = bar();
  let.asyncMap z = baz();
  x + y + z;
}
aantron commented 4 years ago

Yes, there will have to be a separate suffix for map. I haven't tried it yet, but maybe we can do let.async.map or similar.

Just thinking now, another option, which would clearly associate the let with the module that's being used, is let.promise (and let.promise.map).

jordwalke commented 4 years ago

I'm not sure that let.x.y is currently supported, but could be. I really wish users didn't have to deal with thinking about map. One of the reasons is that it makes it really hard to copy/paste/rearrange/insert new async let bindings within a series.

aantron commented 4 years ago

In my experience, in practice, you almost never end up using map with let-like bindings, because a sequence of lets is actually expanded to nested code like this:

flatMap(p1, v1 =>
  flatMap(p2, v2 =>
    flatMap(p3, v3 =>
       ...)))

rather than what we typically see written manually without let,

p
->flatMap(v1 => ...)
->flatMap(v2 => ...)

Because of the types, there is less opportunity to insert a map in the nested calls anyway. You can usually only insert it as the final let. In practice, for me, that has always meant simply not using map at all, and using resolved manually.

jordwalke commented 4 years ago

Can you show an example of what this would look like using the syntax extension:

Because of the types, there is less opportunity to insert a map in the nested calls anyway. You can usually only insert it as the final let. In practice, for me, that has always meant simply not using map at all, and using resolved manually.

aantron commented 4 years ago

@jordwalke I added an experimental Promise.Let module in branch let:

https://github.com/aantron/promise/blob/c85cef9384363ab6ebad03735b19e0d80c963650/src/native/promise.rei#L252-L260

https://github.com/aantron/promise/blob/c85cef9384363ab6ebad03735b19e0d80c963650/src/native/promise.re#L586-L589

It's in Promise native only for now, because I didn't want to mess around with getting a development Reason into BuckleScript.

I added some tests for the syntax, you can run them with dune exec test/test_main.exe.


Unlike map, get composes nicely with nesting, and I've seen it used successfully with bs-let.

BTW don't worry about the underscore in promise_map. It should be a dot, if we can add support for that. But, because of the above, I'd rather add promise.get instead, and maybe promise.ok for flatMapOk.

aantron commented 4 years ago

To summarize the above comment, people won't be inserting maps into sequences of lets. And, it's best not to use map even at the end of such sequences, because that actually harms composability since you can't do async operations anymore under the map.

lessp commented 4 years ago

I've been fiddling around a bit with using let-operators. I find it pretty difficult thinking about what could be defined as future conventions and best practices, but thought I'd share some ideas! Hope you're OK with some bikeshedding! 😅

Map the whole API to let-operators:

let.flatMap
let.map
// ... etc

let.flatMapOk {Response.body, _} = fetch(”...”);

Provide flatMap and map (with result-types), pro's are probably mostly for new onboarding:

/* flatMap */
let.await 
/* or, to follow OCaml-conventions */
let*await

/* map */
let+await

/* flatMapOk */
let.await?

/* mapOk */
let+await?
aantron commented 4 years ago

@lessp have you written code that substantially uses let.map? Curious about your experience with it in light of https://github.com/aantron/promise/issues/52#issuecomment-589982030.

lessp commented 4 years ago

@lessp have you written code that substantially uses let.map? Curious about your experience with it in light of #52 (comment).

Ok, so as an experiment, and trying to take on the role as a guinea pig, I used the https://hacker-news.firebaseio.com/v0/-API to:

/* retrieve all ID's of the top stories */
let.flatMapOk {Response.body} = fetch(".../v0/topstories.json");

/* parse the JSON into a list of postIds */
let postIds = /* ... implementation */

/* retrieve a post for each postId */
let.flatMap rawPosts = 
  posts 
  |> List.map(postId => fetch(".../v0/item/" ++ postId ++ ".json")) 
  |> Promise.all;

/* parse the JSON into a list of posts */
let posts = /* ... implementation */

Promise.resolved(Ok(posts)); /* Ok(posts) - if we'd use let.map above */

I'm not sure if this is helpful, but for me, who's main-reference of async/await is from JavaScript, I think the biggest confusion is the fact (correct me if I'm wrong here), that every function that uses "await" now returns a Promise.t('a). So the explicit wrapping actually helps in making that a bit clearer.

Don't know if that helps!

Hackernews Endpoints let base = "https://hacker-news.firebaseio.com/v0"; let topStories = base ++ "/topstories.json"; let newsStories = base ++ "/newstories.json"; let showStories = base ++ "/showstories.json"; let askStories = base ++ "/askstories.json"; let jobStories = base ++ "/jobstories.json"; let item = id => base ++ "/item/" ++ id ++ ".json";
Kingdutch commented 4 years ago

Drive by comment below :D

As a programmer who comes from PHP, JavaScript, some Rust to ReasonML, I'd much prefer sticking to the await keyword for "waiting for an async operation to resolve to a value":

  1. It better signals that that statement will hold up your program and not perform any work in the meantime.
  2. It's consistent with other languages so makes switching between JS, Rust, Reason easier.
lessp commented 4 years ago

Drive by comment below :D

As a programmer who comes from PHP, JavaScript, some Rust to ReasonML, I'd much prefer sticking to the await keyword for "waiting for an async operation to resolve to a value":

  1. It better signals that that statement will hold up your program and not perform any work in the meantime.
  2. It's consistent with other languages so makes switching between JS, Rust, Reason easier.

The conclusion I arrived at, and the problem as I see it using await is that it implies that it's somehow special-cased, and as long as it is not, it might lead to unnecessary confusion in the long run.

We're already using flatMap, map and other operations for option, result and more so as soon as people get used to that concept I think it'll make sense?

let.flatMap user = Some(user);
let.flatMap user = Ok(user);
let.flatMap user = IO.retrieveUser(~id=1);

I'm not very familiar with Rust, but IIUC you do not have to worry about mapping in its implementation of async/await?

Kingdutch commented 4 years ago

I'm not very familiar with Rust, but IIUC you do not have to worry about mapping in its implementation of async/await?

Not entirely sure if I'm interpreting your question correctly. As far as I'm aware Rust doesn't do anything special to await a promise (known as Future in Rust) returned from a function you're awaiting.

Rust's implementation of async/await compiles away to a state machine. So you'd be left with a certain value in a certain state, that you'd have to .await to move on. So the user would have to worry about the return type and whether that's a future or some other type.

I think my main problem with flatMap is that I also associate it with arrays and data transformations. Awaiting a promise can be seen as a data transformation. However, using that name for awaiting a promise means that in code it's harder to distinguish where you're accidentally suspending.

Rust's definition for flatMap is

An iterator that maps each element to an iterator, and yields the elements of the produced iterators.

Even though a promise wraps a value, I don't see it as an iterator. The definition of let.flatMap above would probably be something like

Awaits any promises that are returned until a resolved or rejected non-promise-value is reached.

alexeygolev commented 4 years ago

I think one can alias flatMap to await if that's more convenient. flatMap makes sense because Promise (within the lib, not the original JS one) is a monad. Rust has map for functors such as Result and Option and and_then for monads. Reason community landed on flatMap for (containerType('a), ('a) => containerType('b)) => containerType('b) (so did JS and Scala), while Haskell and OCaml call it bind. Using map and flatMap for Option and Result when programming in ReasonML removes the "iteratior" idea from those functions context.

My main point is flatMap means the same thing for all monads. Whether something is suspending or not is irrelevant thanks to this library.

lessp commented 4 years ago

I'm not very familiar with Rust, but IIUC you do not have to worry about mapping in its implementation of async/await?

Not entirely sure if I'm interpreting your question correctly. As far as I'm aware Rust doesn't do anything special to await a promise (known as Future in Rust) returned from a function you're awaiting.

Sorry, what I meant was that, if Reason decided to go with something like:

let foo: unit => Promise.t(User.t);

let async foo = () => {
  let.await result = IO.retrieveUser(~id=1);

  result;
};

instead of:

let foo: unit => Promise.t(User.t);

let foo = () => {
  let.flatMap result = IO.retrieveUser(~id=1);

  Promise.resolve(result); 
  /* or `result`, if the final operation would be map instead of flatMap */ 
};

Then, IMHO, it might make more sense to use await.

...and as I understand it, in Rust, you'd do it similarly to that first example where you do not have to map over the result, but in Reason, you would. Hopefully someone can and will correct me if I'm wrong here. 🙂

alexeygolev commented 4 years ago

@lessp Rust handles async/await as a separate language feature (similar to js). In Reason it's just another monad. This allows for using different implementations for async tasks. As long as they conform to being a monad the syntax will work with them. So one could use Future or some other async implementation if they need.