tc39 / proposal-observable

Observables for ECMAScript
https://tc39.github.io/proposal-observable/
3.08k stars 90 forks source link

Retain core API and leave operators to user-land libraries #210

Closed alshdavid closed 1 year ago

alshdavid commented 3 years ago

Motivation

The motivation behind this proposal is the need for JavaScript to have a formal interface describing streamable types. You can see this in the way Observable-like implementations are re-implemented time and time again.

While the browser has the EventTarget interface, allowing objects to implement the addEventListener method - this interface is lacking on Node and further, requires consumers to specify the event name and submit messages through initializing an Event. Great for HTMLElement instances that have several event types (such as click, hover), but less logical for basic streamable data.

The Observable specification, much like Promise, provides a standardised interface that everyone can target.

It's my belief that operators should be dropped from the specification as they can be implemented by user-land libraries and seem to serve to distract from the core value of the proposal.

Lastly, with so many competing implementations, it's much harder to pipe data from one stream into another without hand written adapters bridging the separate stream implementations.

The Whole Thing

Base Type

Implementation via Github gist Example implementation in replit

type Callback<T extends Array<any> = [], U = void> = (...args: T) => U

class Observable<T> {
  constructor(
    setup: Callback<[
      Callback<[T]>,   // Value
      Callback<[any]>, // Error
      Callback,        // Complete
    ], void | Callback<[], any>>
  )

  subscribe(
    value: Callback<[T], any>,
    error?: Callback<[unknown], any>,
    complete?: Callback<[], any>
  ): Subscription
}

class Subscription {
  unsubscribe(): void
}

Usage

const values = new Observable((next, _error, complete) => {
  next('foo')
  setTimeout(complete, 1000)
})

const subscription = values.subscribe(
  console.log, // "foo"
  console.error,
  console.warn, // Will run with empty message
)

Async

Observable will execute synchronously unless an asynchronous action happens in the execution chain somewhere. Such as a fetch in the Observable constructor or a setTimeout in the Subscriber

Errors

If something throws inside an Observable setup callback, an error is pushed to subscribers. If the error method is called, an error is pushed to subscribers.

const values = new Observable(() => {
  throw new Error('Foobar')
})

const subscription = values.subscribe(
  console.log, 
  console.error, // typeof Error message "Foobar"
)

Interfaces

type Callback<T extends Array<any> = [], U = void> = (...args: T) => U

interface Subscriber<T> {
  subscribe(
    value: Callback<[T], any>,
    error?: Callback<[unknown], any>,
    complete?: Callback<[], any>
  ): Unsubscriber
}

interface Unsubscriber {
  unsubscribe(): void
}

What about Subject, ReplaySubject, etc?

These are fantastic types that help with the control flow of streamable values, but can all be made from the base Observable. class. and should therefore be left to user-land libraries to implement

class Subject extends Observable {
  // ...
}

Operators

The use of operators on the Observable type reminds me a bit of how libraries like Bluebird had operators on Promise. Operators being a part of rxjs and rxjs being so popular makes them feel like they are required for Observable to be useful but it's important to recognise that Observable is simple a standard interface to enable the consumption of streamed values. The reactive extensions are there to enhance that behaviour.

While operators may be ergonomic, they are not necessary for the handling of streamable data and can be implemented by user libraries

An example of how operators could be used as implemented by a user-land library is as follows:

const values = new Observable(next => {setInterval(next, 1000, 1)})

const modifiedValues = pipe(values)(
  filter(value => value > 0),
  map(value => value.toString()),
)

modifiedValues.subscribe(console.log)

Conversion to Promise

Much like the challenges described by the rxjs team on what it even means to convert a stream to a Promise, this falls into the domain of user-land libraries.

const values = new Observable(next => {setInterval(next, 1000, 1)})

firstValueFrom(values).then(console.log)
const values = new Observable((_next, _error, complete) => {setTimeout(complete, 1000, 1)})

lastValueFrom(values).then(console.log)

Conclusion

Keeping the API limited in scope allows solving the major issue with streams in JavaScript, a consistent target API.

benjamingr commented 3 years ago

While the browser has the EventTarget interface, allowing objects to implement the addEventListener method - this interface is lacking on Node

This isn't actually true anymore, Node already ships EventTarget and it's even already stable.

and further, requires consumers to specify the event name and submit messages through initializing an Event. Great for HTMLElement instances that have several event types (such as click, hover), but less logical for basic streamable data.

Oh there are a million problems with that interface that we (as in "people who care a lot about the web platform") should address (I have a list somewhere I think?) - I don't think it "competes" with observables although observables in the web platform could complement it.

It's my belief that operators should be dropped from the specification as they can be implemented by user-land libraries and seem to serve to distract from the core value of the proposal.

That's such a good idea and people agree on it that it's already the case :] Look at the spec.

runarberg commented 3 years ago

I remember sitting down one weekend earlier this year having fun writing operators for the new proposed API, and it was an absolute joy. And if a pipe operator gets approved that allows point free style, I bet such libraries will flourish.

kirly-af commented 3 years ago

Hi @benjamingr,

I'd be interested understanding your view on why Observable wouldn't "compete" with previous interfaces (EventTarget, EventEmitter). My understanding was that in most cases it would replace those APIs, how do you see it? I guess older APIs would still make sense for low level implementations (like ports of Web APIs in Node.js).

Either way, you seem to know a lot on this proposal. Do you have an idea of if it planned to have it presented for stage 2 any time soon? Nice to see the spec got significantly smaller anyway.

Thanks!

benjamingr commented 3 years ago

Either way, you seem to know a lot on this proposal. Do you have an idea of if it planned to have it presented for stage 2 any time soon? Nice to see the spec got significantly smaller anyway.

The proposal is mostly blocked on someone putting in the (admittedly large) amount of work in to prepare it and present it. The committee isn't actually opposed to it. To quote a TC39 member I talked to about this last week "TC39 is not opposed to observables, it is more that no one is working on it"


I'd be interested understanding your view on why Observable wouldn't "compete" with previous interfaces (EventTarget, EventEmitter).

EventTarget and EventEmitter are not a part of JavaScript. EventEmitter is a part of Node.js and EventTarget is a part of WHATWG/dom (and also Node.js).

EventTarget/EventEmitter don't actually compose (you can't really easily "map" them) and they're a much higher level abstraction (observables are fundamentally much simpler).

dy commented 2 years ago

TC39 is not opposed to observables, it is more that no one is working on it

What work would it require?

This proposed version seems to be the minimal viable standard needed for interop in subscribable-things, observable-value, hyperf and others.

benjamingr commented 2 years ago

@dy I think the first step would be to find someone from TC39 who is willing to sit down with you and enumerate the things needed to move observable forward (show a good DOM interop story, deal with existing concerns, show viability in language APIs, write an explainer on where push is required etc).

Maybe @ljharb would know what would be an appropriate way to reach out to champions?

ljharb commented 2 years ago

Posting on https://es.discourse.group is your best bet.

benlesh commented 2 years ago

And if a pipe operator gets approved that allows point free style, I bet such libraries will flourish.

This isn't going to happen. I'd love it if it did. But it's thoroughly blocked by influential people.

benlesh commented 2 years ago

FYI: There are more Observable-like implementations in the wild:

I've seen other very, very similar types in dozens of lesser known libraries, as well. A WAMP client I've used comes to mind.

benlesh commented 2 years ago

Oh, and @alshdavid, what you have above is missing an important aspect of Observable, which is how to finalize what you've set up during subscription. You need to either pass in some sort of token/signal to register finalization on, or you need to allow the user to return a function that will be called during finalization, or something to that effect.

benlesh commented 2 years ago

This is probably a some what duplicate of this issue from 2019

dy commented 2 years ago

Posted to es.discourse.

dy commented 2 years ago

One thing is missing here. API must include [Symbol.observable] for interop:

class Observable<T> {
    ...
    // Returns itself
    [Symbol.observable]() : Observable;
    ...
}
Pyrolistical commented 2 years ago

How does this solve the composition problem? https://github.com/tc39/proposal-observable/blob/master/ObservableEventTarget.md#problem-eventtargets-and-promises-are-difficult-to-compose

alshdavid commented 1 year ago

Closing this as the discussion has been moved here https://es.discourse.group/t/observables/1175/4