Open tbondwilkinson opened 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?
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.
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.
The two ways people generally convert an observable to a promise are:
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.
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
.
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.
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.
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.
As a user, why would you ever want an observable instead of a Promise here?
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 |
Iterable → Iterator |
Async pull | Promise-returning function → Promise |
AsyncIterable → AsyncIterator |
Push sync | ??? | Observable → Observer + 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.
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.