Skip to content
Light and type-safe binding to JS promises
Reason Shell
Branch: master
Clone or download

Latest commit

Fetching latest commit…
Cannot retrieve the latest commit at this time.

Files

Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.github Try GitHub Sponsors Jan 22, 2020
src Native: adapt to result 1.5 on 4.08+ Feb 27, 2020
test Fix some spacing Jan 7, 2020
.gitignore opam release script Feb 13, 2020
.travis.yml Travis: upgrade opam to 2.0.6 Feb 23, 2020
LICENSE.md Update copyright year Dec 23, 2019
README.md README: fix section heading level Feb 19, 2020
bsconfig.json Rename library Sep 22, 2019
dune Speed up Dune Sep 21, 2019
dune-project Convert from Jbuilder to Dune Jul 26, 2018
package.json Bump to 1.0.2 Jan 18, 2020
promise.opam opam release script Feb 13, 2020

README.md

Promise     Version 1.0.2 Travis status Coverage

A super light and type-safe binding to JS promises.

Promise.resolved("Hello")->Js.log;  /* 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 applies to 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 doesn't allow this at all. reason-promise emulates it in a way that makes its API type-safe. This is in contrast to BuckleScript's built-in Js.Promise, which exposes the JS behavior that silently flattens nested promises, with the result that the API in BuckleScript has incorrect types.

In addition:


Installing

Run

npm install reason-promise

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

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

Tutorial

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, which gives you a promise and a function for resolving it:

let (p, resolve) = Promise.pending();
Js.log(p);    /* 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 running 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");   /* "Hello" is logged. */

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 or 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:


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 repo.

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

[%%bs.raw {|
function delay(value, milliseconds) {
  return new Promise(function(resolve) {
    setTimeout(function() { resolve(value); }, milliseconds)
  });
}|}]

[@bs.val]
external delay: ('a, int) => Promise.t('a) = "delay";

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. Here is one way to do that:

[%%bs.raw {|
function delayReject(value, milliseconds) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() { reject(value); }, milliseconds)
  });
}|}]

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

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

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

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

[%%bs.raw {|
function log(p) {
  p.then(function (v) { console.log(v); });
}|}]

[@bs.val]
external log: Promise.t('a) => unit = "log";

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

As always, it is important to be careful about the set of values that a promise can be resolved or rejected with, since JS can return anything :) Additional JS code may be necessary to handle this, as with any JS binding.


Discussion: Why JS promises are unsafe

The JS function Promise.resolve has a special check for whether the value being put into a promise is another promise or not. Unfortunately, this check makes it impossible to assign JS's Promise.resolve a correct type in Reason (and most type systems).

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

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

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

Js.Promise.resolve("foo")->Js.log;
/* 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 this:

let anotherPromise = Js.Promise.resolve(1);
Js.Promise.resolve(anotherPromise)->Js.log;
/* Promise { Promise { 1 } } */

We would expect the result to have type Js.Promise.t(Js.Promise.t(int))... but that's not what happens! Instead, the output is just

/* Promise { 1 } */

The nested promise is missing! When you pass anotherPromise to Js.Promise.resolve, JS sneakily unwraps anotherPromise, violating the type! This is special-case behavior that JS runs only when the value is a promise (technically, a "thenable"), and there is no way to easily encode such special casing in the type system.

The result is, if your program executes something like this, it will have ordinary values in places where it expects promises. For example, if you call then_ on the promise above, you would expect the program to see a promise containing 1 in the callback. Instead, the callback will get just 1, causing a runtime error as soon as the program tries to use promise functions on the 1.

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 used safely from Reason.


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 representation is not. That means that to make JS promises type-safe, we can keep the same data representation, and just provide safe replacement functions to use with it in Reason. 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 value = Promise.resolved(1);
    Promise.resolved(value)->Js.log;
    /* Promise { PromiseBox { Promise { 1 } } } */

    This box, of course, is not a promise, so it's enough to bypass the special-casing.

    Whenever you try to take the value out of this resulting promise (for example, by calling Promise.get on it), reason-promise transparently unboxes the nested promise before passing it to your callback.

This conditional boxing and unboxing is done throughout reason-promise. It only happens for nested promises. 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.

You can’t perform that action at this time.