[Help] About promises flattening

Hello everyone,

it’s me again :smile:. In the attempt of fully understanding nuances between ReScript and JavaScript/TypeScript, I was studying and producing some small playgrounds. Reading the documentation, I’ve stumbled on this:

In JS, nested promises (i.e. promise<promise<'a>> ) will automatically collapse into a flat promise (promise<'a> ). This is not the case in ReScript.

So, as far as my understanding goes, the equivalent of this JavaScript code

const doSomethingElse = (val) => new Promise((resolve) => resolve(val))
const doSomething = () => new Promise((resolve) => resolve("done"))

const taskList = doSomething().then(res => {
  console.log(`First task ${res}`);
  
  return doSomethingElse(res);
}).then(res => console.log(`All tasks ${res}`));

should not produce the same result, which is not the case. In fact, the following code

let doSomethingElse = (res) => Js.Promise2.make(((~resolve, ~reject as _) => resolve(. res)))
let doSomething = () => Js.Promise2.make(((~resolve, ~reject as _) => resolve(. "done")))

let taskList = doSomething()
  ->Js.Promise2.then(res => {
    Js.log(`First task: ${res}`)

    doSomethingElse(res)
  })->Js.Promise2.then(res => {
    Js.log(`All tasks: ${res}`)
    Js.Promise2.resolve()
  })->ignore;

works just fine. I’m confused, what am I missing?

Thanks!

I suspect your confusion may be in the types of your functions and the argument of then.

In the example you link, it is using the async/await keywords, and the async functions return a promise, so you can run in to the nesting issue as mentioned in the blurb in the docs there.

In your then chain example, your doSomething* functions both return promises, and that is the type required by the callback to then (ie 'a => promise<'b>). But, the then function callbacks aren’t implicitly wrapping the return value in a promise like in the async example, so you don’t get the nested promise in the code you showed.


Btw, when in doubt, it can be helpful to annotate all your functions. And even to pull out your callbacks to then, write them as named functions and annotate the types. Like this:

(playground)

let doSomethingElse: 'a => promise<'a> = res =>
  Js.Promise2.make((~resolve, ~reject as _) => resolve(. res))
let doSomething: unit => promise<string> = () =>
  Js.Promise2.make((~resolve, ~reject as _) => resolve(. "done"))

let f1: string => promise<string> = res => {
  Js.log(`First task: ${res}`)

  doSomethingElse(res)
}

let f2: string => promise<unit> = res => {
  Js.log(`All tasks: ${res}`)
  Js.Promise2.resolve()
}

let taskList = doSomething()->Js.Promise2.then(f1)->Js.Promise2.then(f2)->ignore
2 Likes

Hey Ryan,

thanks for your answer, makes perfectly sense to me. However I still don’t get how this related to flattening. This code in JavaScript

const doSomething = () => new Promise((resolve) => resolve("done"))

const taskList = async () => {
  return doSomething()
};

let make = (async() => {
  console.log(await taskList());
})();

is equivalent to

let doSomething = () => Js.Promise2.make(((~resolve, ~reject as _) => resolve(. "done")))

let taskList = async () => {
  doSomething() // no await here
}

let make1 = (async () => { Js.log(await taskList())})(); // this prints out "done" and not "Promise<{pending>}"

so both JavaScript and ReScript are effectively returning a promise<promise<'a>> and in both cases the result is flattened.

I don’t know if it’s a naming problem here, but when talking about JavaScript, flattening is (to my understanding) what I’ve just described: all nested promises are returned as a flat one, so instead of having to rely on multiple await (or nested then for that matter), you can operate on the outer level because it will always resolve in a non-thenable value (reference: Promise.resolve).

My other guess is that documentation mention something related purely to ReScript that get lost once transpiled to JavaScript, but it wouldn’t make that much of a sense.

I’m still confused :rofl:,

Best!

The distinction is perhaps that promise<promise<'a>> does not really have a meaning in JS.
In TS, the promise type is effectively flattened, and in ReScript it is not. This is one difference. The runtime though is the same.

3 Likes

A-ha, bingo!

Thanks, I figured that out. I think this is still somehow misleading in the way is phrased on the documentation since it’s more about inferred type than anything else. Computationally wise, the only significant different is that you need to explicitly resolve any deferred value or the ReScript compiler will raise an error.

So, this means that the following code

const doSomething = () => new Promise((resolve) => resolve("done"))

const taskList = async () => {
  return doSomething()
};

let make = (async() => {
  taskList().then(res => console.log(res));
})();

will work in ReScript only if you “manually” resolve the promise, as shown below.

let doSomething = () => Js.Promise2.make(((~resolve, ~reject as _) => resolve(. "done")))

let taskList = async () => {
  doSomething()
}

let make1 = (() => {
  taskList()
  ->Js.Promise2.then(res => {
    Js.log(res)
    Js.Promise2.resolve() // omitting this will generate an error at compile time 👍
  })
  ->ignore
})();

so I would say that there’s evidence about this mostly when using then or when explicitly declaring our (expected) return type.

Thanks everyone, it’s clear now.

Best!

As a side note, it’s unclear whether it is possible to flatten the promise type while maintaining type safety. That’s why ReScript does not flatten.

2 Likes

Can you pin-point me to the phrases that were misleading? I would like to fix them if they are unclear. Maybe it’s because we are talking about type behavior vs. runtime behavior? In ReScript things won’t compile if the types don’t match, so I thought it would be clear that we are only talking about the type space.

1 Like

Maybe it’s because we are talking about type behavior vs. runtime behavior?

Precisely. It is also somehow weird that you can Js.log(res) when res is a promise<string> (type wise) while at run-time it gets flatten to string anyway, but from a type point of view it makes sense and it’s definitely more consistent than saying “a Promise will always flatten to a non then-able value” which created a lot of confusion over the years in JavaScript community (there’s a reason why async / await is suggested over promises chaining).

Anyway, now that I know what I’m talking about I can safely complete my next blog post :laughing:.

As per the documentation, maybe adding a “ReScript vs TypeScript” snippet highlighting the fact in TypeScript you’ve a Promise<T> instead of a promise<promise<'a>> can help.

The part that are misleading are:

  • here where it says “promise values and types returned from an async function don’t auto-collapse into a “flat promise” like in JS (more on this later)”, which is clearly not that case since values are flattened (since values belong to run-time, I think);
  • here where it says “This is not the case in ReScript. Use the await function to manually unwrap any nested promises within an async function instead.” which is somehow confusing since this is not what is happening in this example.

Best!

1 Like

Maybe the part in the docs that says “This is not the case in ReScript.” should be something that emphasizes that while the runtime compiled js behavior isn’t different, at the type level there is a difference. It seems that the heart of the issue was the difference between type and runtime here (OP, please correct me if I misinterpreted you).

(Here is the docs sentences I’m talking about)

In JS, nested promises (i.e. promise<promise<'a>>) will automatically collapse into a flat promise (promise<'a>). This is not the case in ReScript.

3 Likes

Okay this sounds sensible. Will think about some rephrasing!

1 Like