8000 Why do some operators return Promises? · Issue #20 · WICG/observable · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Why do some operators return Promises? #20

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
tbondwilkinson opened this issue Jun 6, 2023 · 9 comments
Open

Why do some operators return Promises? #20

tbondwilkinson opened this issue Jun 6, 2023 · 9 comments

Comments

@tbondwilkinson
Copy link

I think people may look at that long operator list and wonder whether this is the MVP list of operators or not.

So some justification for why this list of operators is the right one would be good.

@tbondwilkinson
Copy link
Author

I guess it's just copying the TC39 proposal. Let me rephrase this question

I think my confusion is why some instance methods return Promises vs Observables?

Like toArray and take also operate on multiple values in sequence, just like some or every does, but only some or every return Promises? Why do some methods operate on the current sequence but other methods wait for potentially more values?

@tbondwilkinson tbondwilkinson changed the title Some discussion of why the operators were chosen would be good Why do some operators return Promises? Jun 6, 2023
@benlesh
Copy link
Collaborator
benlesh commented Jun 7, 2023

Primarily because they only return one value. However it's reasonable to have them return observables, perhaps just not ergonomic in the common case. RxJS's versions of these methods return observables.

@tbondwilkinson
Copy link
Author

I think it would be clearer for them to returns Observables, and there be some way to turn Observables into Promises in general, like with nextValue or nextValues() for an array of Promises.

@benlesh
Copy link
Collaborator
benlesh commented Jun 8, 2023

The two ways people generally convert an observable to a promise are:

  1. Take the first value, unsubscribe, then resolve.
  2. Track the last value, wait for completion, then resolve with it.

The only gotcha is if the observable completes without emitting a value. In those cases, RxJS has found it's best to reject the returned promise with an error (in our case an EmptyError that is custom to RxJS). Because it's essentially like reading an empty vector or array. There's nothing there. However, in the case of Arrays, obviously ([])[0] is just undefined and not an error... so I'm willing to debate the behavior there.

But I'm amenable to having everything return observables, but providing two methods like first() or firstValue() and last() or lastValue() that return observables.

@tbondwilkinson
Copy link
Author

Another option is methods that are named with then to denote that they return Promise instead of Observable, like firstThen(). Or firstPromise().

My main feedback is I think it should be clear in a chain of Observable calls when you get a Promise vs. an Observable.

@benlesh
Copy link
Collaborator
benlesh commented Jun 8, 2023

I'm concerned about the readability. In RxJS, we export lastValueFrom and firstValueFrom to convert to promises. However, that would look weird as static methods on Observable, I think. Maybe Promise should own conversions? I don't know.

Although if we wanted, there could be a single method at() that accomplished the same thing, with prior art being Array#at.

There are a lot of options:

// 1. xValue() method.
await someObservable$.firstValue();
await someObservable$.lastValue();

// 2. xThen() method.
await someObservable$.firstThen();
await someObservable$.lastThen();

// 3. Static Observable methods
await Observable.firstValue(someObservable$);
await Observable.lastValue(someObservable$);

// 4. Static Promise methods
await Promise.firstValueFrom(someObservable$);
await Promise.firstValueFrom(asyncIterable);
await Promise.lastValueFrom(someObservable$);
await Promise.lastValueFrom(asyncIterable);

// 5. "at" (ala Array#at)
await someObservable$.at(0);
await someObservable$.at(-1);

I have mixed feelings about all of these.

Number 1 is the one I like the best. Mostly because it's easy to read, and there's some prior art in RxJS, which I'm used to.

Number 5 is the most flexible, and has prior art in the language (that is arguably not well known, I still see new arr[arr.length - 1] every day, and I think I forget and reach for it too, when I'm in a hurry). But it still doesn't quite show that there's a Promise involved.

Number 4 is probably a no-go, I don't think I'd want to alter a common type like Promise that extends beyond the browser's runtime. That's more of a TC39 thing. Although, if Promise ever got anything that did this with AsyncIterable, and Observable implements Symbol.asyncIterator, it would "just work".


Finally, there was a "once upon a time" where the Observable completed with the last value, and was also "thennable", meaning calling observable$.then(console.log) would subscribe to the observable in a non-cancellable way and log the last value. Therefor things like await observable$.first() would "just work" even though it returned an observable.

That was scrapped because subscribing to an observable can have side effects, and it was concerned to be too confusing for folks that awaiting something could trigger a side effect, when with promises, the side effect was always underway prior to the await.

@benlesh
Copy link
Collaborator
benlesh commented Jul 28, 2023

Honestly, I was thinking about this and some of the same arguments could exist here that exist in the iterator-helpers proposal: Where map and filter return new iterators, but some, or find return values.

The difference is here they have to be promises, because the result would come over an indeterminate amount of time. Because it's pushed at you.

@bakkot
Copy link
Contributor
bakkot commented Jul 28, 2023

Relevantly, the current plan is for async iterator helpers (which are stage 2) to have map return an async iterator, and some return a Promise.

Honestly I can't really imagine doing it another way. In that proposal, as in this one, some gives you precisely one value which will be realized in the future, and Promise is the right type for that concept.

Screenshot 2023-07-28 at 3 12 10 PM

As a user, why would you ever want an observable instead of a Promise here?

@Jamesernator
Copy link
Contributor

As a user, why would you ever want an observable instead of a Promise here?

The main reason is that it is synchronous, this means if .complete() is called part of some event for example it can still use .preventDefault() and such.

Though I don't believe that most users would need the synchronous observation for most of the single value returning methods anyway. Why? Well the reason is simple, the other operators don't expose any value from calling subscriber.complete() anyway so there's nothing to respond to at the point of the aggregate being available.

e.g. Consider this example:

// Also note by the time eventCount is set, all mousemove events have long since lost the
// opportunity to have preventDefault called as well
const eventCount = await div.on("mousemove").takeUntil(div.on("mouseup")).reduce((acc, event) => {
   return acc + 1;
}, 0);

the synchronous .complete() point corresponds to the "mouseup" event, however the reducer has no access to this event anyway because .takeUntil doesn't actually send the mouseup anywhere (and similar is true for all the other aggregate methods proposed). If people want behaviour acting on the mouseup event, they need to attach a .tap or such that observable directly or write their own combinator that both performs the reduction and gives them the event in one observable.

Honestly I can't really imagine doing it another way. In that proposal, as in this one, some gives you precisely one value which will be realized in the future, and Promise is the right type for that concept.

Although this is how observables were presented even in the original TC39 proposal, I don't think they've ever really fitted this table properly, I would consider a more accurate table:

Single value source → value Multiple value source → multiple values
Sync pull Function → value IterableIterator
Async pull Promise-returning functionPromise AsyncIterableAsyncIterator
Push sync ??? ObservableObserver + Subscription

For the most part observable proponents argue that ??? should also be Observable so that one only needs one type. Personally I have never liked this approach as it just makes for a weird abstraction, like we could represent all single-value pull sources as iterables that emit a single value, but people would rightfully consider that ridiculous in most cases.

Though as mentioned above, the aggregate operators proposed don't really need the singular-value sync behaviour anyway, and basically all new host APIs tend to be explictly designed for promises, so I have minimal concern about the non-existence of a singular push-sync type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants
0