Consider making operators return `this.constructor`-typed objects, instead of native `Observable`
See https://github.com/WICG/observable/issues/178#issuecomment-2625603343 and all of the discussion following it. This is required to support ease of subclassing, and it may be required to support buffering, which as @benlesh describes in the aforementioned issue, is important for BehaviorSubject.
If we decide this is a good thing to do, I prefer to treat it as a follow-up. It introduces a behavior change—operators called on Observable subclasses will suddenly start returning the subclassed type (as JS Arrays and I think other built-ins do), instead of platform Observables—but I think this is probably OK since if you're subclassing you're kind of voiding the warranty anyways. But since the ref counted producer issues are closing soon, we'll use this issue to track discussion on this topic.
I've spoken to some folks more familiar with Chromium bindings about the possibility of bringing this subclassing behavior to Observables, and I was pointed to https://github.com/tc39/proposal-rm-builtin-subclassing, which I didn't know existed! It seems like the kind of subclassing proposed here is a big regret of the language, and folks like @syg are working hard to stamp (almost all of) if out: https://github.com/tc39/proposal-rm-builtin-subclassing?tab=readme-ov-file#proposed-new-old-semantics.
Given this, it seems like what's asked here is an anti-pattern that should probably not escape the language itself and spread to broader web APIs and Web IDL world, would you agree with this @syg?
We've found that adding explicit support for subclassing built-ins (in the sense of adding hooks like Symbol.species or having methods defer to other methods like how the Set constructor calls this.add) has caused a great deal of implementation complexity and provided almost no benefit. It adds overhead to the common case (people who are using the base class) for the benefit of allowing people to write less code in the uncommon case (people who are writing a subclass), and that tradeoff doesn't seem worth it.
So I would personally leave out any explicit support for subclassing. If you want a subclass to customize the behavior of methods, including to return something other than an instance of the base class, you have to override the methods you're customizing.
That said, maybe subclassing Observable is expected to be more common, or maybe there's some reason this is particularly necessary for Observable as opposed to other classes.
Note that the proposal here is for type II subclassing support, which the document notes as "Beneficial, but at cost."
I tend to think the cost might be worth it. Let's look at how costly it would be for people to subclass manually:
class SubObservable extends Observable {
constructor() {
console.log("creating SubObservable");
super();
}
}
const observableReturningMethods = ['takeUntil', 'map', 'filter', 'take', 'drop', 'flatMap', 'switchMap', 'inspect', 'catch', 'finally'];
for (const method of observableReturningMethods) {
SubObservable.prototype[method] = function (...args) {
return observableToSubObservable(super[method](...args));
};
}
const staticObservableReturningMethods = ['from'];
for (const staticMethod of staticObservableReturningMethods) {
SubObservable[staticMethod] = function (...args) {
return observableToSubObservable(Observable[staticMethod](...args));
};
}
function observableToSubObservable(observable) {
// TODO how hard is it to write this?
}
This has two brittle points:
- You have to manually maintain a list of observable-returning methods and static methods. Not the worst thing in the world, but it does mean new library revisions if the platform adds more operators to observables.
- I don't know how hard to write
observableToSubObservableis.
It might be reasonable to have people experiment with this approach and see how painful it is, before baking it into the platform.
There is a possible compat issue though, if we encourage subclassing which works one way and in the future it works differently. Like, depending on how observableToSubObservable is written, I could imagine it becoming an infinite recursion if we upgraded Observable.prototype.map from returning new Observable() to returning new this.constructor().
That said, maybe subclassing Observable is expected to be more common, or maybe there's some reason this is particularly necessary for Observable as opposed to other classes.
The main reason is for the reason mentioned in the linked issue, to implement BehaviourSubject (and similar such subjects).
In practice one could probably accomplish BehaviourSubject and similar with the current proposal without subclassing and instead just returning new observables each time you want to use the subject. i.e. defining behaviour subjects roughly as:
class BehaviourSubject<T> {
#value: T;
readonly #subscribers = new Set<Subscriber<T>>());
constructor(initialValue: T) {
this.#value = initialValue;
}
next(value: T): void {
this.#value = value;
for (const subscriber of this.#subscribers) {
subscriber.next(value);
}
}
// ...And so forth for other methods
// Create a new observable each time this subject needs to subscribe
toObservable(): Observable<T> {
return new Observable(subscriber => {
this.#subscribers.add(subscriber);
subscriber.addTeardown(() => {
this.#subscribers.delete(subscriber);
});
subscriber.next(this.#value);
});
}
}
const subject = new BehaviourSubject<number>(10);
// Both subscriptions see the both values
subject.toObservable().forEach(v => {
console.log(v);
});
subject.toObservable().forEach(v => {
console.log(v);
});
subject.next(20);
While this involves creating new observables rather than re-using them, this is essentially already how other things like iterators work (i.e. you can't re-use an iterator to see the previous values).
Note that the proposal here is for type II subclassing support, which the document notes as "Beneficial, but at cost."
That is technically true, but even considering that cost-benefit analysis the TC39 proposal still aims on getting rid of Type II: https://github.com/tc39/proposal-rm-builtin-subclassing?tab=readme-ov-file#exit-criteria. Is it so beneficial to Observables that it's worth breaking from TC39 on this?
function observableToSubObservable(observable) { // TODO how hard is it to write this? }
I don't know how hard to write
observableToSubObservableis.
Unfortunately I think @benlesh and I decided that it might actually be impossible to write that method, given the discussion in https://github.com/WICG/observable/issues/178 and chats elsewhere.
In practice one could probably accomplish BehaviourSubject and similar with the current proposal without subclassing and instead just returning new observables each time you want to use the subject. i.e. defining behaviour subjects roughly as:
@benlesh Does this seem like a feasible workaround in user-land, to get around non-native Type II inheritance?