Giter Site home page Giter Site logo

promise's Introduction

Promise     NPM link Travis status Coverage

A lightweight, type-safe binding to JS promises:

Js.log(Promise.resolved("Hello"));  /* Promise { 'Hello' } */

Promise.resolved("Hello")
->Promise.map(s => s ++ " world!")
->Promise.get(s => Js.log(s));      /* Hello world! */

As you can see on the first line, Promise.t maps directly to familiar JS promises from your JS runtime. That means...

  • You can use reason-promise directly to write JS bindings.
  • All JS tooling for promises immediately works with reason-promise.
  • Even if you do something exotic, like switch out the promise implementation at the JS level, for, say, better stack traces, reason-promise still binds to it!

There is only one exception to the rule that Promise.t maps directly to JS promises: when there is a promise nested inside another promise. JS breaks the type safety of promises in a misguided attempt to disallow nesting. reason-promise instead emulates it in a way that makes promises type-safe again. This is in contrast to BuckleScript's built-in Js.Promise, which directly exposes the JS behavior, and so is not type-safe.


In addition:


Tutorial


Installing

npm install reason-promise

Then, add reason-promise to your bsconfig.json:

{
  "bs-dependencies": [
    "reason-promise"
  ]
}

Getting started

To quickly get a project for pasting the code examples, clone the example repo. The code is in main.re.

git clone https://github.com/aantron/promise-example-bsb
cd promise-example-bsb
npm install
npm run test    # To run each example.

There it also an example repo with a trivial binding to parts of node-fetch.

While reading the tutorial, it can be useful to glance at the type signatures of the functions from time to time. They provide a neat summary of what each function does and what it expects from its callback.


Creating new promises

The most basic function for creating a new promise is Promise.pending:

let (p, resolve) = Promise.pending()
Js.log(p)     /* Promise { <pending> } */

The second value returned, resolve, is a function for resolving the promise:

let (p, resolve) = Promise.pending()
resolve("Hello")
Js.log(p)     /* Promise { 'Hello' } */

Promise.resolved is a helper that returns an already-resolved promise:

let p = Promise.resolved("Hello")
Js.log(p)     /* Promise { 'Hello' } */

...and Promise.exec is for wrapping functions that take callbacks:

@bs.val external setTimeout: (unit => unit, int) => unit = "setTimeout"

let p = Promise.exec(resolve => setTimeout(resolve, 1000))
Js.log(p)     /* Promise { <pending> } */

/* Program then waits for one second before exiting. */

Getting values from promises

To do something once a promise is resolved, use Promise.get:

let (p, resolve) = Promise.pending()

p->Promise.get(s => Js.log(s))

resolve("Hello")    /* Prints "Hello". */

Transforming promises

Use Promise.map to transform the value inside a promise:

let (p, resolve) = Promise.pending()

p
->Promise.map(s => s ++ " world")
->Promise.get(s => Js.log(s))

resolve("Hello")    /* Hello world */

To be precise, Promise.map creates a new promise with the transformed value.

If the function you are using to transform the value also returns a promise, use Promise.flatMap instead of Promise.map. Promise.flatMap will flatten the nested promise.


Tracing

If you have a chain of promise operations, and you'd like to inspect the value in the middle of the chain, use Promise.tap:

let (p, resolve) = Promise.pending()

p
->Promise.tap(s => Js.log("Value is now: " ++ s))
->Promise.map(s => s ++ " world")
->Promise.tap(s => Js.log("Value is now: " ++ s))
->Promise.get(s => Js.log(s))

resolve("Hello")

/*
Value is now: Hello
Value is now: Hello world
Hello world
*/

Concurrent combinations

Promise.race waits for one of the promises passed to it to resolve:

@bs.val external setTimeout: (unit => unit, int) => unit = "setTimeout"

let one_second = Promise.exec(resolve => setTimeout(resolve, 1000))
let five_seconds = Promise.exec(resolve => setTimeout(resolve, 5000))

Promise.race([one_second, five_seconds])
->Promise.get(() => {
  Js.log("Hello")
  exit(0)
})

/* Prints "Hello" after one second. */

Promise.all instead waits for all of the promises passed to it, concurrently:

@bs.val external setTimeout: (unit => unit, int) => unit = "setTimeout"

let one_second = Promise.exec(resolve => setTimeout(resolve, 1000))
let five_seconds = Promise.exec(resolve => setTimeout(resolve, 5000))

Promise.all([one_second, five_seconds])
->Promise.get(_ => {
  Js.log("Hello")
  exit(0)
})

/* Prints "Hello" after five seconds. */

For convenience, there are several variants of Promise.all:


Handling errors with Result

Promises that can fail are represented using the standard library's Result, and its constructors Ok and Error:

open Belt.Result

Promise.resolved(Ok("Hello"))
->Promise.getOk(s => Js.log(s))       /* Hello */

Promise.getOk waits for p to have a value, and runs its function only if that value is Ok(_). If you instead resolve the promise with Error(_), there will be no output:

open Belt.Result

Promise.resolved(Error("Failed"))
->Promise.getOk(s => Js.log(s))       /* Program just exits. */

You can wait for either kind of value by calling Promise.getOk and Promise.getError:

open Belt.Result

let () = {
  let p = Promise.resolved(Error("Failed"))
  p->Promise.getOk(s => Js.log(s))
  p->Promise.getError(s => Js.log("Error: " ++ s))
}                                     /* Error: Failed */

...or respond to all outcomes using the ordinary Promise.get:

open Belt.Result

Promise.resolved(Error("Failed"))
->Promise.get(result =>
  switch result {
  | Ok(s) => Js.log(s)
  | Error(s) => Js.log("Error: " ++ s)
  })                                  /* Error: Failed */

The full set of functions for handling results is:

There are also similar functions for working with Option:

In addition, there is also a set of variants of Promise.all for results, which propagate any Error(_) as soon as it is received:

If you'd like instead to fully wait for all the promises to resolve with either Ok(_) or Error(_), you can use the ordinary Promise.all and its variants.


Advanced: Rejection

As you can see from Handling errors, Promise doesn't use rejection for errors — but JavaScript promises do. In order to support bindings to JavaScript libraries, which often return promises that can be rejected, Promise provides the Promise.Js helper module.

Promise.Js works the same way as Promise. It similarly has:

However, because Promise.Js uses JS rejection for error handling rather than Result or Option,

Underneath, Promise and Promise.Js have the same implementation:

type Promise.t('a) = Promise.Js.t('a, never)

That is, Promise is really Promise.Js that has no rejection type, and no exposed helpers for rejection.

There are several helpers for converting between Promise and Promise.Js:

Promise.Js.catch can also perform a conversion to Promise, if you simply convert a rejection to a resolution. In the next example, note the final line is no longer using Promise.Js, but Promise:

Promise.Js.rejected("Failed")
->Promise.Js.catch(s => Promise.resolved("Error: " ++ s))
->Promise.get(s => Js.log(s))         /* Error: Failed */

There are also two functions for converting between Promise.Js and the current promise binding in the BuckleScript standard libarary, Js.Promise:

Because both libraries are bindings for the same exact kind of value, these are both no-op identity functions that only change the type.


Advanced: Bindings

Refer to the example node-fetch binding repo.

When you want to bind a JS function that returns a promise, you can use Promise directly in its return value:

/* A mock JS library. */
%%bs.raw(`
function delay(value, milliseconds) {
  return new Promise(function(resolve) {
    setTimeout(function() { resolve(value); }, milliseconds)
  });
}`)

/* Our binding. */
@bs.val external delay: ('a, int) => Promise.t('a) = "delay"

/* Usage. */
delay("Hello", 1000)
->Promise.get(s => Js.log(s))

/* Prints "Hello" after one second. */

If the promise can be rejected, you should use Promise.Js instead, and convert to Promise as quickly as possible, with intelligent handling of rejection. Here is one way to do that:

/* Mock JS library. */
%%bs.raw(`
function delayReject(value, milliseconds) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() { reject(value); }, milliseconds)
  });
}`)

/* Binding. */
@bs.val external delayRejectRaw: ('a, int) => Promise.Js.t(_, 'a) = "delayReject"
let delayReject = (value, milliseconds) =>
  delayRejectRaw(value, milliseconds)
  ->Promise.Js.toResult

/* Usage. */
delayReject("Hello", 1000)
->Promise.getError(s => Js.log(s))

/* Prints "Hello" after one second. */

Note that this binding has two steps: there is a raw binding, and then an extra wrapper that converts rejections into Results. If the potential rejections are messy, this is a good place to insert additional logic for converting them to nice ReScript values :)

When passing a promise to JS, it is generally safe to use Promise rather than Promise.Js:

/* Mock JS library. */
%%bs.raw(`
function log(p) {
  p.then(function (v) { console.log(v); });
}`)

/* Binding. */
@bs.val external log: Promise.t('a) => unit = "log"

/* Usage. */
log(Promise.resolved("Hello"))        /* Hello */

Discussion: Why JS promises are unsafe

The JS function Promise.resolve has a special case, which is triggered when you try to resolve a promise with another, nested promise. Unfortunately, this special case makes it impossible to assign Promise.resolve a consistent type in ReScript (and most type systems).

Here are the details. The code will use Js.Promise.resolve, BuckleScript's direct binding to JS's Promise.resolve.

Js.Promise.resolve takes a value, and creates a promise containing that value:

Js.log(Js.Promise.resolve(1))
/* Promise { 1 } */

Js.log(Js.Promise.resolve("foo"))
/* Promise { 'foo' } */

So, we should give it the type

Js.Promise.resolve: 'a => Js.Promise.t('a)

and, indeed, that's the type it has in BuckleScript.

Following the pattern, we would expect:

let nestedPromise = Js.Promise.resolve(1)

Js.log(Js.Promise.resolve(nestedPromise))
/* Promise { Promise { 1 } } */

But that's not what happens! Instead, the output is just

/* Promise { 1 } */

The nested promise is missing! But the type system, following the pattern, still thinks that this resulting value has type

Js.Promise.t(Js.Promise.t(int))

i.e., the type of the value we were (reasonably) expecting.

When you pass nestedPromise to Js.Promise.resolve, JS unwraps nestedPromise, violating the type! There is no easy way to encode such special casing in the type system — especially since JS does it not only to nested promises, but to any would-be nested object that has a .then method.

The result is, if your program executes something like this, it will have ordinary values in places where it expects another level of promises. For example, if you do

let nestedPromise = Js.Promise.resolve(1);

Js.Promise.resolve(nestedPromise)
->Js.Promise.then_(p => /* ... */)

you would expect p in the callback to be a promise containing 1, and the type of p is indeed Js.Promise.t(int). Instead, however, p is just the bare value 1. That means the callback will cause a runtime error as soon as it tries to use promise functions on the 1. Worse, you might store p in a data structure, and the runtime error will occur at a very distant place in the code. The type system is supposed to prevent such errors! That's part of the point of using ReScript.

The same special casing occurs throughout the JS Promise API — for example, when you return a promise from the callback of then_. This means that most of the JS Promise functions can't be assigned a correct type and directly, safely be used from ReScript.


Discussion: How reason-promise makes promises type-safe

The previous section shows that JS promise functions are broken. An important observation is that it is only the functions that are broken — the promise data structure is not. That means that to make JS promises type-safe, we can keep the existing JS data structure, and just provide safe replacement functions to use with it in ReScript. This is good news for interop :)

To fix the functions, only the special-case flattening has to be undone. So, when you call reason-promise's Promise.resolved(value), it checks whether value is a promise or not, and...

  • If value is not a promise, reason-promise just passes it to JS's Promise.resolve, because JS will do the right thing.

  • If value is a promise, it's not safe to simply pass it to JS, because it will trigger the special-casing. So, reason-promise boxes the nested promise:

    let nestedPromise = Promise.resolved(1)
    
    Js.log(Promise.resolved(nestedPromise))
    /* Promise { PromiseBox { Promise { 1 } } } */

    This box, of course, is not a promise, so inserting it in the middle is enough to suppress the special-casing.

    Whenever you try to take the value out of this resulting structure (for example, by calling Promise.get on it), reason-promise transparently unboxes the PromiseBox and passes the nested promise to your callback — as your callback would expect.

This conditional boxing and unboxing is done throughout reason-promise. It only happens for nested promises, and anything else with a .then method. For all other values, reason-promise behaves, internally, exactly like JS Promise (though with a cleaner outer API). This is enough to make promises type-safe.

This is a simple scheme, but reason-promise includes a very thorough test suite to be extra sure that it always manages the boxing correctly.

This conditional boxing is similar to how unboxed optionals are implemented in BuckleScript. Optionals are almost always unboxed, but when BuckleScript isn't sure that the unboxing will be safe, it inserts a runtime check that boxes some values, while still keeping most values unboxed.

promise's People

Contributors

aantron avatar alextes avatar andreypopp avatar ckknight avatar cknitt avatar jihchi avatar johnhaley81 avatar mseri avatar ncthbrt avatar sikanhe avatar zploskey avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

promise's Issues

Bikeshedding is welcome!

Please do discuss any such issues. We want to make a pleasant API. We are keeping the Repromise version in 0.x.y for now to allow plenty of room for breakage :)

Add "promisify" function

let p = Repromise.promisify(resolve => resolve());

This is basically the new Promise constructor. It's more convenient when you already have a function that takes a callback, that you can call inside promisify. The existing Repromise.make is more convenient when you need to pass resolve to somewhere later, or store it.

Not sure about the name yet :)

map may need to have its return value wrapped

Repromise.resolve(1) |> Repromise.map(x => Repromise.resolve(x + 1))

In the type system should be Repromise.promise(Repromise.promise(int)), but at runtime will ostensibly collapse to Repromise.promise(int).

I believe the way to fix this will be to have separate map and then_ JS functions where map makes sure to call wrap on the return value of the callback provided to map, which would prevent the nested resolution.

Add coverage analysis, Coveralls

Bisect_ppx now supports both native and BuckleScript coverage, so use it.

  • make coverage for local reports.
  • Submit to Travis, from two build rows (opam and npm).
  • Add coverage badge.
  • JS: fix [@coverage exclude(file)] upstream, and adapt to that.
  • JS: fix ppx-flags being global, and adapt to that. aantron/bisect_ppx#221
  • JS: fix that instrumentation should automatically only be enabled in dev builds, perhaps also when some flag is specified. aantron/bisect_ppx#221
  • Native: wait for ocaml/dune#57, and adapt to that.
  • JS, esy: relax 4.06 OCaml constraint for development. See aantron/bisect_ppx#220.

Coerce to JS.Promise

There are various Bucklescript libraries today that take Js.Promises are arguments (i.e. bs-express). It would be great to use Repromise with these libraries!

Would it be possible to have a coerce function that converts a Repromise promise to a Js.Promise?

Thanks,
Louis

Nesting a repromise inside a promise is not sound

This perhaps might not be a use case you want to support, but if so this limitation should be clearly documented. To give a specific example, Js.Promise.resolve(Repromise.resolve(5)) will collapse.

This of course has implications for interop with existing javascript libraries. A function which has signature 'a => Js.Promise.t('a) can not be soundly typed should 'a be of type Repromise.t('b).

This fork of vow is able to solve this case: http://github.com/ncthbrt/vow but is not without the additional overhead of an extra wrapper.

First-class Js_of_ocaml support

Repromise compiles to js_of_ocaml right now, but the resulting type doesn't map onto JavaScript promises directly, like it does on BuckleScript. This issue proposes to provide a direct implementation of Repromise on Js_of_ocaml as JavaScript promises.

Converting from Js.Promise

Greetings, @aantron.

Would it be possible on providing some guidance on converting from Js.Promise to reason-promise? It seems like in many cases, that is what someone might be trying to do.

The library in question is reason-apollo-client. The branch has a bunch of other improvements, includes reason-promise as the default promise implementation. That branch is the next branch.

By way of example, found at reason-promise-question, I have a Js.Promise function that gets me the current token value. It has the following type:

unit => Js.Promise.t(
  Js.Nullable.t(string)
)

The function can be found in Tokens and is reproduced below. This is a dummy function, there is no binding. The point is to see how to get it to compile.

[@bs.val] [@bs.scope "localStorage"]
external setItem: (string, string) => unit = "setItem";
let setUserToken: string => Js.Promise.t(unit) =
  token => Js.Promise.resolve(setItem("auth", token));

[@bs.val] [@bs.scope "localStorage"]
external getItem: string => Js.Nullable.t(string) = "getItem";
let getUserToken: unit => Js.Promise.t(Js.Nullable.t(string)) =
  () => Js.Promise.resolve(getItem("auth"));

let setTempUserToken: string => Js.Promise.t(unit) =
  _ => Js.Promise.resolve();

let getTempUserToken: unit => Js.Promise.t(Js.Nullable.t(string)) =
  () => Js.Promise.resolve(Js.Nullable.undefined);

When I try use this with reason-promise when creating an apollo/client authlink, I get the following error:

unit => Js.Promise.t(
  Js.Nullable.t(string)
)
Error: This expression has type
         Js.Promise.t(Js.Json.t) = Js.Promise.t(Js.Json.t)
       but an expression was expected of type
         Promise.t(Js.Json.t) = Promise.rejectable(Js.Json.t, Promise.never)

Here is the authlink function:

let authLink =
  ApolloClient.Link.ContextLink.makeAsync((~operation as _, ~prevContext as ctx) => {
    Tokens.getUserToken()
    ->Js.Promise.then_(
        token => {
          switch (token->Js.Nullable.toOption) {
          | None =>
            Tokens.getTempUserToken()
            ->Js.Promise.then_(
                token => Js.Promise.resolve(Js.Nullable.toOption(token)),
                _,
              )
          | Some(token) => Js.Promise.resolve(Some(token))
          }
        },
        _,
      )
    ->Js.Promise.then_(
        fun
        | None => Js.Promise.resolve(Js.Json.null)
        | Some(token) => {
            Js.Promise.resolve(
              [%raw
                {| (context, token) => ({
                headers: {
                  ...ctx.headers,
                  authorization: `Bearer ${token}`
                }
              }) |}
              ](
                ctx,
                token,
              ),
            );
          },
        _,
      )
  });

How do we convert this to a reason-promise? Please feel free to be as diadactic as you want to be.

Thank you, in advance.

I have reposted on stackoverflow if you prefer to answer there. The link is https://stackoverflow.com/questions/64294223/converting-from-js-promise-to-reason-promise-in-reasonml

Fix type variables cannot be generalized when using NPM

When another NPM project depends on Repromise, NPM does not properly set up a symlink from src/js/repromise.rei to src/repromise.rei. As a result, some values in src/js/repromise.re are not properly constrained, resulting in the error message in the title.

This is probably best solved by moving src/repromise.rei into src/js, and adjusting the symlink in src/native accordingly.

Usage of Printexc increases the bundle size quite a bit

I'm having a great experience with Repromise, but had to give up on it for a project which aims for the smallest bundle size that's easily achievable. I noticed only Repromise depends on CamlinternalFormat in this particular case, and switching back to Js.Promise resulted in a much smaller bundle.

Is this dependency easily avoidable in Repromise when targeting BuckleScript?

I created a Gist with rollup-plugin-analyzer to demonstrate where most of the 41k bundle (minified) comes from: https://gist.github.com/osener/91dd9838200d53c0b3bf824a9394885c

You can try it out by running

$ git clone https://gist.github.com/91dd9838200d53c0b3bf824a9394885c.git repromise_bundle_size_test
$ cd repromise_bundle_size_test
$ npm install
$ npm run build

Provide type abbreviation for promises that reject with Js.Promise.error

From Discord:

nitpicking, you could save a line by binding login directly as a reason-promise:

external login: unit => Promise.Js.t(authResponse, Js.Promise.error) = "login"

then you can get rid of the ->Promise.Js.fromBsPromise, which is a no-op at run time anyway.

looking at that, it might be good to add a helper type abbreviaton to the library, so you can do something like

extern login: unit => Promise.Js.raw(authResponse) = "login"

Rename Repromise.then_ to Repromise.andThen

See discussion starting from #22 (comment).

This is pretty much a search-and-replace task through src/, test/, and doc/. The only thing to watch out for is that some instances of the text "then_" are actually referring toJs.Promise.then_, and those shouldn't be replaced. They are always prefixed with Js.Promise, though, so should be easy to see.

This is a nice easy issue for taking a glance at the whole codebase.

Wrap lwt and Async?

Most native reason code is going to be wrapping opam libraries for doing things like http requests and database queries, and those native libraries are going to be using either lwt or Async. It would be great if you could, for example, have an http client library that used either bs-fetch or cohttp under the hood, depending on the platform, and exposed a common api for both. Is this something that it would make sense for repromise to do, or would it make more sense for that to be a separate library?

Pipe last implementation

Previously Promise was pipe-last. Within the BuckleScript the main API's are pipe-first (a separate discussion 😄). For me, who's also interested in the native side, where pipe-last undoubtedly is the norm, what would be your view on providing both options?

Clarify "JS promises" vs. "Js.Promise" in the docs

I'm confused by the top of the docs? Shouldn't Repromise work with js promises?

/* Aren't all js promises automatically repromises?
Interop — each Repromise is a JS promise, so JS libraries that return promises already return Repromises, and existing JS tooling should work. */

Js.Promise.resolve(1)
|> Repromise.map(id)

/* [merlin]
Error: This expression has type
         Repromise.promise('a) => Repromise.promise('a)
       but an expression was expected of type Js.Promise.t(int) => 'b
       Type Repromise.promise('a) = Repromise.rejectable('a, Repromise.never)
       is not compatible with type Js.Promise.t(int) = Js_promise.t(int) */


Fetch.fetch("https://www.reddit.com/r/programming.json")
|> Repromise.map(id)

/* [merlin]
Error: This expression has type
         Repromise.promise('a) => Repromise.promise('a)
       but an expression was expected of type
         Js.Promise.t(Fetch.response) => 'b
       Type Repromise.promise('a) = Repromise.rejectable('a, Repromise.never)
       is not compatible with type
         Js.Promise.t(Fetch.response) = Js_promise.t(Fetch.response) */

Awesome library by the way! Thank you.

Fix native CI in Travis

The native side of Repromise depends on libuv 1.x. Travis, AFAICT, runs Ubuntu 14.04, in which has libuv 0.10 (a really old version).

I think the options are a container, CircleCI, PPA, or building libuv from source.

Add `Promise.both`

This is useful for the and* binding operator.

Currently I'm using:

  let both = (x, y) => {
    Promise.all([x, y])
    ->Promise.map(
        fun
        | [x, y] => (x, y)
        | _ => assert(false),
      );
  };

Reduce compiled size again

See #54 (comment). It is currently a bit under 4K over 1K (thanks @cknitt), up from an earlier ~950 bytes, but, per the linked comment, there is likely a lot of the new stuff that can be easily reduced.

Proposal: Observable Streams

Node.js's built in streams have awkward semantics which do not fit well into a functional programming paradigm. Observable Streams have much better semantics and at least in theory should be able to accurately capture the behavior of Node's streams. Could have a nice symmetry with promises. If carefully implemented, should only have a small constant additional overhead compared to a naïve interpretation of the bindings.

Streams are used fairly heavily throughout the node APIs, so it's important that they have a sound foundation.

Promise.allOk and its variations

Hi,

Thank you for this great library! I was wondering it would be possible to add Promise.allOk (i.e. a result version of Promise.all) and its variations? At the moment, I believe there is nothing provided that replicates the fail-fast behavior of JavaScript's Promise.all with the result type. Thanks for your consideration!

`value instanceof Promise` won't work for promise-likes

This wouldn't be wrapped by the current implementation but also collapses:
Promise.resolve({ then: (res) => res({ then: r => r (5)}) }).then(console.log)

A suggestion would be to wrap any object containing a .then function.

Make native reason-promise covariant, like JS

c9d8008 marked JS promises covariant. We should do the same in the native version. However, that is a full implementation, and a correct implementation of promises usually prevents the compiler from admitting covariance (the compiler infers invariance). This is because such an implementation requires several helper data structures to defer callbacks and avoid memory leaks, so we need to either:

  • Add casts to fool the compiler (as in Lwt).
  • Search more thoroughly for data structures that don't prevent the compiler from inferring covariance.

The current data structures don't (or shouldn't!!) make the promises invariant, the compiler just can't tell that they don't.

After dealing with this, the covariance tests should be made to run (rather, build) on both JS and native:

promise/test/js/test_ffi.re

Lines 316 to 360 in c9d8008

let covarianceTests = Framework.suite("covariance", [
test("promise", () => {
let p: Promise.t(subtype) = Promise.resolved(`A);
let p: Promise.t(type_) = (p :> Promise.t(type_));
ignore(p);
Promise.resolved(true);
}),
test("ok", () => {
let p: Promise.t(result(subtype, unit)) = Promise.resolved(Ok(`A));
let p: Promise.t(result(type_, unit)) =
(p :> Promise.t(result(type_, unit)));
ignore(p);
Promise.resolved(true);
}),
test("error", () => {
let p: Promise.t(result(unit, subtype)) = Promise.resolved(Error(`A));
let p: Promise.t(result(unit, type_)) =
(p :> Promise.t(result(unit, type_)));
ignore(p);
Promise.resolved(true);
}),
test("option", () => {
let p: Promise.t(option(subtype)) = Promise.resolved(Some(`A));
let p: Promise.t(option(type_)) = (p :> Promise.t(option(type_)));
ignore(p);
Promise.resolved(true);
}),
test("fulfillment", () => {
let p: Promise.Js.t(subtype, unit) = Promise.Js.resolved(`A);
let p: Promise.Js.t(type_, unit) = (p :> Promise.Js.t(type_, unit));
ignore(p);
Promise.resolved(true);
}),
test("rejection", () => {
let p: Promise.Js.t(unit, subtype) = Promise.Js.rejected(`A);
let p: Promise.Js.t(unit, type_) = (p :> Promise.Js.t(unit, type_));
p->Promise.Js.catch(_ => Promise.resolved())->ignore;
Promise.resolved(true);
}),
]);

Rename Repromise.new_ to Repromise.make

There's a pretty strong consensus about this in the bikeshedding issue (#22) and in Discord chat, so let's do it :)

This is basically a search-and-replace job through all the files in src/, test/, and doc/.

Proposal: Monadic operators

Repromise introduces monadic behaviour to promises.

As a convenience, it would be nice to have a map operator with the signature ('a => 'b, Repromise.t('a)) => Repromise('b). This makes Repromise have semantics which are as convenient as javascript's automatic wrapping (in js Promise.resolve(4).then(a => a * 2) returns a promise). If this were to be adopted, it would also be wise to add the function flatMap which would simply be an alias for then_.

BuckleScript 8.0.0 breaks listToArray / Promise.all

The internal representation of lists changed in BuckleScript 8.0.0. Lists are now encoded as {hd, tl}, not as nested arrays anymore.

This breaks the functions listToArray and arrayToList and thereby all functions relying on them (Promise.all, Promise.race).

Installing in ReasonReact Project

If i have this package.json

  "dependencies": {
    "@glennsl/bs-json": "^1.1.2",
    "bs-fetch": "^0.2.1",
    "material-design-icons": "^3.0.1",
    "materialize-css": "^0.100.2",
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "reason-react": "^0.3.2",
    "repromise": "github.com/aantron/repromise"
  },

I get this project install in the project: https://github.com/tracker1/repromise. Is that expected?

This syntax in package.json gets you the correct repo: "repromise": "github:aantron/repromise"

About the deprecation of the infix operators

I realise that the let bindings replaces them rather elegantly in most situations
But I am personally a very mix and match-y type where my JS codebases uses promise chains where they make sense and async/await where they makes sense and will be doing the same with Reason
Worth considering whether that's something others could want to do.
I don't mind this being removed from the library since I'll just redefine it in my code anyway but thought it was worth mentioning!

RFC: what to do about promise rejection

Repromise is a proposed Reason binding to JavaScript promises.

Adding types to promises raises some new design questions, rejection being especially tricky. TL;DR: the current proposal is not to allow rejection in the main API at all.

I'm posting this issue to explain why that is, and to offer some notes about other approaches considered. Everything is open to experimentation and redesign, so please discuss :)

Feel free to skip around and read only some parts. The first two sections are important because they describe the problem and the main design considerations. Everything after that is a dump of attempted solutions and other thoughts.


The current main Repromise API looks like this:

type repromise('a);
  /* Only fulfillment type, 'a. */

let new_: unit => (promise('a), 'a => unit);
  /* Returns only a promise, plus function to fulfill it. */

let resolve: ...
let then_: ...
  /* No Repromise.reject, no Repromise.catch. */

There is a separate API, Repromise.Rejectable, that does have rejection, and provides catch for converting to "normal," non-rejectable repromises. This separate API is meant to be used mainly for writing the internals of bindings to JS libraries.






Background: JS interop

There are two parts to how Repromise interops with JS:

  1. How a repromise can be passed to JS.
  2. What to do with a promise coming from JS.
    • ...one that can't be rejected, and
    • ...one that can be rejected.

Passing repromises to JS is easy: every repromise is actually implemented as a JS promise, so you can declare bindings like

[@bs.val]
external takesPromise: Repromise.t(int) => string = "";

For receiving promises from JS, if the JS API provides a promise that the API never rejects, that promise can also be typed directly as a normal repromise:

[@bs.val]
external givesPromise: string => Repromise.t(int) = "";

The remaining, tricky case is when the JS API provides a promise that can be rejected. This is what Repromise.Rejectable is for:

module Repromise.Rejectable = {
  type rejectable('a, 'e);

  let new_: unit => (rejectable('a, 'e), 'a => unit, 'e => unit);
    /* This new_ returns a way to reject the promise. */

  let reject: 'e => rejectable(_, 'e);
  let catch:
    ('e => rejectable('a, 'e2), rejectable('a, 'e)) => rejectable('a, 'e2);

  let then_: ...
    /* The rest of the types differ from base API by having 'e parameter. */
};

Usage:

/* Externally, in the .rei file. */
let givesPromise: string => Repromise.t(result(int, char));


/* Internally, in the .re file. */
[@bs.val]
external givesRejectablePromise: string => Repromise.Rejectable.t(int, char);

let givesPromise = s =>
  givesRejectablePromise(s)
  |> Repromise.Rejectable.then_(i => Repromise.Rejectable.resolve(Ok(i)))
  |> Repromise.Rejectable.catch(c => Repromise.resolve(Error(c)));

Actually, the normal Repromise and Repromise.Rejectable have exactly the same implementation:

type repromise('a) = rejectable('a, never);

The types of all the functions, and that there is no way to construct never, mean that Repromise.Rejectable.catch must be called to convert rejectable repromises into normal ones. This is meant to encourage(/force? :p) bindings authors to offer any error-handling strategy other than native JS rejection: the example above suggests result. The reason for that is explained in the next section.

I am not sure how useful Repromise.Rejectable would actually be in practice. For example, if a JS API rejects a promise with two different types, then you have to write custom JS code to bind it. A special, and perhaps common, case of this is a JS API that explicitly rejects promises with some type like int, but also raises exceptions that get converted into rejections by JS.


Problem: Difficulties typing rejection

Briefly consider fulfillment instead of rejection. In JS, any promise can be fulfilled with a value of any type. You can race an array of promises for ints and strings, and the resulting promise can be fulfilled with either int or string.

In Reason, however, the "obvious" promise API looks like this:

type promise('a);
let race: list(promise('a)) => promise('a);

...and that constrains you to racing only promises that can be fulfilled with values of the same, one type. Typing benefits the safety of the API elsewhere, but it makes race more restrictive, to an extent not directly related to safety.

Nonetheless, the restriction is still pretty reasonable.


Back to rejection: in JS, any promise can also be rejected with a value of any type. The most "obvious" encoding of this is probably what's done in Repromise.Rejectable:

type promise('a, 'e);
let race: list(promise('a, 'e)) => promise('a, 'e);

...except now, you can only race promises that are both fulfilled and rejected with values of the same respective, single types. In practice, this seems way too restrictive and annoying. Rejection is typically a bit of a background mechanism or an afterthought, so it is very bad when interference from typing rejection prevents you from writing a reasonable program.

A problem arises even with then:

type promise('a, 'e);
let then_: ('a => promise('b, 'e), promise('a, 'e)) => promise('b, 'e);

/* usage: */
... promise |> then_ callback ...

Now, the first promise, representing a "first" asynchronous operation, and the second promise, returned by the callback, have to be rejectable with the same type.

This means that actually taking advantage of the fact that 'e is a type parameter, and using different types for it, will probably make most code too difficult to refactor or write. For the code to be composable, it will often have to use both then_ and catch right next to each other, in order to be able to vary both 'a and 'e from one promise to the next.


Attempt 1: Unityped rejection

This suggests a solution: make one good choice of a type for 'e, and always use that. This is actually pretty common:

  • JS has unityped rejection: with its only type of "any JS value."
  • Lwt has unityped rejection: with exn (native OCaml exceptions).
  • The previous point suggests rejection with any other single open variant type (type rejected = ..;). I chose not to commit to this because you can't have an exhaustive pattern-matching on an open variant type, and because it's not directly compatible with rejections coming from JS anyway: JS will never generate OCaml open variant values.

There are two "degenerate" choices of one type for rejection:

  • Rejection with "no payload"/unit/Top: if we want to propagate rejections using the native JS rejection mechanism, but don't want to bother accurately typing it and don't want to allow values to be carried around by it.
  • No rejection/Bottom: this is what Repromise currently has. There is no way to construct never, so there is no way to trigger rejection of a normal repromise.

Attempt 2: Polymorphic variants

Polymorphic variants have the advantage that the compiler can often simply infer the correct rejection variands. However, almost every user of Repromise would likely have to deal with reading and writing either complex type annotations

let mySimpleFunction: int => Repromise.t(char, [> `String of string]);

or casts

takesPromise((p :> Repromise.t(char, [`String of string])));

In fact, in many places, one is forced to choose one or the other. This seems like a major drawback for a general-use library.

I also considered polymorphic variants for a phantom type to express the distinction between rejectable and non-rejectable promises:

type non_rejectable = [ `Non_rejectable ];
type rejectable('e) = [ non_rejectable | `Rejectable('e) ];

let new_: unit => (promise('a, [> non_rejectable]), ...);
let newRejectable: unit => (promise('a, rejectable('e)), ...);

let then_:
  ('a => promise('b, [> non_rejectable] as 'r), promise('a, 'r)) =>
    promise(b, 'r);

...but it's not clear what is achieved at the cost of such complexity, compared to two separate APIs as proposed now.

One of the problems with such designs is that they make everyone aware of rejection typing. By constrast, without rejection in the main API, hopefully users that stay in the "Reason world" have much less to learn. Only bindings authors need to deal with more complex types in some cases. Even for bindings authors, however, complex types are not very useful. Bindings authors are working with FFI, so they already have the opportunity to write some code in JS and/or assign arbitrary, but straightforward, types.


More background: What is rejection?

This is probably easiest answered by asking "how is rejection different from fulfillment?" Both resolve a promise, but

  1. Rejection doesn't trigger the callback call in then.
  2. Rejection causes early resolution of all.
  3. Exceptions raised during a callback are converted into rejections.

(1) is properly the job of the option and result types:

type option('a) = None | Some('a);

let mapOption = (callback, option) =>
  switch (option) {
  | None => None
  | Some(x) => Some(callback(x))
  };

We can provide helpers for mixing Repromise with option and result to model this kind of error handling.


(2) seems like just a design decision. Not using native rejection means that if we want early rejection of all, we would have to write our own implementation of all for option and/or result types.


For (3), Repromise currently forwards all uncaught exceptions in callbacks to a function

let onUnhandledException: ref(exn => never);

which kills the program by default. We should probably change this behavior to just printing the stack trace. A promise that would have been rejected by a failing callback in JS is left pending by Repromise.

We can provide a function like

let tryWith: ('a => repromise('b)) => ('a => repromise(result('b, exn)));

for catching exceptions in callbacks and converting them to explicit error handling. We could also extend onUnhandledException into something close to Async monitors.

I'm not yet sure what types to use for exception handling: exn? Js.Exn.t? And, trying to type catching JS exceptions, unwrapping the carried values (of arbitrary type), and using that to reject promises seems like it would create the same unfavorable composability situation as described in the "problem" section.

Dealing with exceptions explicitly, rather than converting them to rejection and propagating rejections around, could be a bit burdensome. But, I think the set of programs people write in Reason can be roughly split into two, and exceptions as rejections might not be that desirable or necessary in either case:

  • High-reliability programming typically wants explicit error handling.
  • In short scripts, etc., the programmer wants to be told about the problem to debug it, but is less concerned exactly how the error is propagated otherwise. Rejection is not necessary here, it's enough to show a good message and/or stack trace.

Provide way to use tuples instead of list for "all"

Hey there! Thanks so much for this library - I'm really enjoying using it in my project over the standard Js.Promise.t!

One API that Js.Promise provides that I do miss, however, is the ability to resolve a set of promises in parallel that don't all have the same type via a tuple.

From the JS side, I think the implementation would be pretty easy:

let all2 = (p1, p2) =>
    [|p1, p2|]
    |> jsAll
    |> map (results => {
      let mappedResults = results |> Array.map(unwrap);
      (mappedResults[0], mappedResults[1])
    });

But, I'm not as familiar with writing native Reason code, so I've got no idea if it'd be that straightforward there... and if it isn't, I presume the idea would be to keep the API the same for native and JS, right?

Exceptions cause resolution with `undefined`

When an exception occurs in a reason-promise, it resolves the promise with undefined. My expectation would be that it either rejects or the type for a result should always be wrapped in an option. Is this intentional?

Promise.resolved(Ok())
->Promise.map(_ => Js.Exn.raiseError("test"))
->Promise.get(result => {
    Js.log2("I wouldn't expect this to log but it will", result);
    switch (result) {
    | Error(_) => Js.log("and will never get here")
    | Ok(_) => Js.log("ok")
    };
  });

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.