WICG / observable

Observable API proposal
https://wicg.github.io/observable/
Other
563 stars 13 forks source link

Why do some operators return Promises? #20

Open tbondwilkinson opened 1 year ago

tbondwilkinson commented 1 year ago

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 commented 1 year ago

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?

benlesh commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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.