Reading the state and value from a Promise

While implementing the CSS View Transitions Specification in JavaScript (see project: view-transitions-mock), two of its steps listed in the spec required me to react to specific Promises.

In “Call the update callback”:

React to callbackPromise with fulfillSteps and rejectSteps.

In “Skip the View Transition”:

Resolve transition’s finished promise with the result of reacting to transition’s update callback done promise:

If the promise was fulfilled, then return undefined.

To do this, I needed access to the state and value of a Promise instance but to my (possibly naive) surprise these properties are not exposed.

Eventually I hacked something (nasty?) together: a WatchablePromise (source) that allows reading the state.

import WatchablePromise from "watchable-promise";

const p = new WatchablePromise(resolve => setTimeout(() => resolve("foo"), 100));

console.log(p.state); // pending
console.log(p.settled); // false
console.log(p.value); // undefined

const val = await p;

console.log(val) // "foo"
console.log(p.state); // fulfilled
console.log(p.settled); // true
console.log(p.value); // "foo"

(The package also comes with a WatchablePromise.from(existingPromise) and WatchablePromise.withResolvers();)

It seems quite fundamental to working with Promises, so perhaps this could be added to the language? Or is this a Very Bad Idea™?

Related threads:

It’s quite intentional - the entire design of promises required that it’s impossible to synchronously determine the state of a Promise.

You can use .then to determine that async any time.

@bramus If you read the "react" steps you linked, you can see that they do not require you to access the Promise's state: they only require you to call Promise.prototype.then(promise, onFulfilled, onRejected).

When it says "If the promise was fulfilled, then return undefined", that means this is the thing that goes in the "If there is a set of steps to be run if the promise was fulfilled" part of the "react" algorithm.

@ljharb I don't disagree that this was the design intent, it doesn't mean it still makes sense though. You've objected to stream iterators for similar reasons. The fact is that when a real system operates it's critically important for perf to be able to know when it's possible to proceed synchronously

1 Like

Isn't the entire point of no-Zalgo that you can't have an API that "smartly" decides to proceed synchronously? Sure you can still write code that synchronously gets the status/value without unleashing Zalgo, but not providing that capability prevents a class of bugs.

Isn't the entire point of no-Zalgo that you can't have an API that "smartly" decides to proceed synchronously?

NO!!!

The Zalgo post tells you one and exactly one thing:

It must never be impossible to tell whether control flow has proceeded synchronously or asynchronously.

That it, no restriction more draconian than that. JS handles it by always proceeding asynchronously, but alerting the user to which thing has happened is an equally acceptable way of fulfilling the requirements for not releasing Zalgo.

So: not only does the post in question NOT say "don't do this", it actually says "do this". Here it is saying that (synthetic deferrals are exactly what JS does do btw):

Avoid Synthetic Deferrals

I know what you’re thinking: “But you just told me to use nextTick!”

And yes, it’s true, you should use synthetic deferrals when the only alternative is releasing Zalgo. However, these synthetic deferrals should be treated as a code smell. They are a sign that your API might not be optimally designed.

Ideally, you should know whether something is going to be immediately available, or not. Realistically, you should be able to take a pretty decent guess about whether the result is going to be immediately available most of the time, or not, and then follow this handy guide:

Not only does it EXPLICITLY say that you should have some points at which control may or may not proceed synchronously, but I actually got isaacs (the post's original author) to come here and refute the interpretation you've offered: for await? .. of - #5 by isaacs

Putting Zalgo to one side.

I think it's important that if an API returns a Promise then that asynchronous contract is enforced.

An API may start by returning Promise.resolve values, the consumer is forced to handle the async case and this makes it easier for that producer to introduce real async work later without breaking the consumer. (Not impossible, but more likely).

If a consumer can take the resolved promise and immediately read it that now puts an architectural debt that the producer was trying to avoid.

Yes it would be explicit in the control flow but just because a workaround is explicit doesn't mean everyone wanted it to exist.

If there was a way to read the status synchronously then someone should still pay the cost of having to wait. In the same way that these APIs are implemented today, by waiting once and then attaching the result to an object. This ensures there is a place that can handle the red to blue call stacking change.

This, I think, is much more levelheaded than the Zalgo argument.

Most likely it would be a breaking change for Promise.resolve(value) not to introduce a synthetic deferral. If there is a synthetic deferral, then you still can't actually use synchronous inspection (except on promises you've already awaited)

Then you'd be in a position to introduce Promise.resolveSync(value), which could allow code which is designed for this mode of operation to take advantage of it.

1 Like

The big question for me is how this relates to the iterator protocol, because iterators are one of the most important applications for code that runs sometimes-synchronously. A sometimes-synchronous iterator is what I would call a "stream".

Streams are a great area to push for progress in because they make data access effortless, especially text. What's brilliant about thinking of text as a character stream is that a string is a character stream, and so is open file handle. You can implement algorithms like decodeUTF8 in a way that's completely agnostic to whether you have the source byte in an array or a file on disk. That's great! That's what a successful abstraction looks like.

In practice it lets API design be simplified. Right now you read files with read(file, encoding). This is technically a mixing of unrelated concerns: one concern is reading the bytes, the other is encoding them. The concerns are mashed together because we lack(ed) the tools to pry them apart. Now the API can be: decodeUTF8(read(file)). I have implemented this here: node-fs/lib/index.js at 6dabc6916082eb09cbeb61ee0d389056b401b6cb · bablr-lang/node-fs · GitHub

This is highly relevant because this proposal implies that all async iterators would become possible stream iterators. Iteration is a protocol which means you don't control how the provider provides the protocol to you. If promises are synchronously inspectable, any defensively minded programmer must consider the possibility that an async iterator contains some synchronously available values. If the async iterator is a file stream, the vast majority of values (bytes) will be available synchronously.

For example:

let iter = thing[Symbol.asyncIterator]();
let step = iter.next();
if (!step.fulfilled) await step;
console.log(step.value); // synchronous inspection

Since the low-level protocol itself suggest the possibility of efficient stream processing by not waiting where waiting is unneeded, I think it only makes sense for the language to also make the same things efficient using high-level protocols.

I have proposed and still recommend: for await? loops as a high-level construct that behaves the way the above example code does, waiting only when the stream requires waiting.

for await? (let chr of textStream) {
  // ...
}

If you can consume this type of stream you're going to want to be able to produce it, and that implies that generator functions are allowed to return sync-inspectable promises from next() under certain conditions.

I still think my proposal here is the most comprehensive way of dealing with all of this: for await? .. of - #19 by conartist6

It still creates an explicit third protocol, but when coercing async iterators to the stream iterator protocol, if both proposals were accepted then we would respect the sync-ness of steps in the async iterator when possible