kosich / rxjs-proxify

Turns a Stream of Objects into an Object of Streams
MIT License
35 stars 3 forks source link

Reserved keys in observable type #9

Open amygrinn opened 3 years ago

amygrinn commented 3 years ago

I figured out how to exclude a set of key names from every level of an object in typescript using a recursive type definition. This would be useful, for example, to restrict someone from calling proxify with an Observable of a type that overrides subscribe.

// deep-omit.d.ts
export type DeepOmit<Reserved extends string> =
  | Boolean
  | Number
  | String
  | Date
  | Function
  | Array<DeepOmit<Reserved>>
  | ({ [key: string]: DeepOmit<Reserved> } & Partial<
      { [key in Reserved]: never }
    >);

Usage:

type S = DeepOmit<keyof Observable>;
// or
type S = DeepOmit<'subscribe' | 'pipe'>;

const test: S = 'a'; // OK
const test: S = false // OK
const test: S = { subscribe: false }; // Type 'boolean' is not assignable to type 'undefined'.
// Deep matching
const test: S = { a: { pipe: 'hello' } }; // Type 'string' is not assignable to type 'undefined'.
const test: S = { a: { piper: 'hi' } }; // OK

But I don't really know how to integrate with the existing typings file you have.

kosich commented 3 years ago

This was bothering me too! Will take a look at this and report back. Thanks! πŸ™‚

markwhitfeld commented 3 years ago

@kosich I really love this lib. I was literally just about to create something like this. I had already done the typings experiment for it.

Just a thought on this issue, I was also looking to address this issue, but decided to solve it with a little bit of convention to keep the apis more distinct. By having two special properties you can switch between introspection and observable: x._ => starts introspection x.$ => goes back to the observable

So this example (from my playing around with this):

const observable = proxify(
  of({ p: 'πŸ‘', foos: ['bar', 'baz'], ping: { pong: { hello: 'world' } } })
);
observable.subscribe(console.log); // > { p: πŸ‘ }
observable.p.subscribe(console.log); // > πŸ‘
observable.foos.subscribe(console.log); // > ['bar', 'baz']
observable.foos[0].subscribe(console.log); // > bar
observable.foos[1].subscribe(console.log); // > baz
observable.foos[5].subscribe(console.log); // > undefined
observable.foos.filter(item => item.includes('z')).subscribe(console.log); // > ['baz']
observable.foos.slice(0, 1).subscribe(console.log); // > ['bar']
observable.ping.pong.hello.subscribe(console.log); // > world
observable.ping.pipe(map(o => o.pong)).hello.subscribe(console.log); // > world

would become:

const observable = proxify(
  of({ p: 'πŸ‘', foos: ['bar', 'baz'], ping: { pong: { hello: 'world' } } })
);
observable.subscribe(console.log); // > { p: πŸ‘ }
observable._.p.$.subscribe(console.log); // > πŸ‘
observable._.foos.$.subscribe(console.log); // > ['bar', 'baz']
observable._.foos[0].$.subscribe(console.log); // > bar
observable._.foos[1].$.subscribe(console.log); // > baz
observable._.foos[5].$.subscribe(console.log); // > undefined
observable._.foos.filter(item => item.includes('z')).$.subscribe(console.log); // > ['baz']
observable._.foos.slice(0, 1).$.subscribe(console.log); // > ['bar']
observable._.ping.pong.hello.$.subscribe(console.log); // > world
observable._.ping.$.pipe(map(o => o.pong))._.hello.$.subscribe(console.log); // > world

Although this adds a small overhead I think that it makes things easier to explore. I found that my IntelliSense was polluted with the observable stuff when I was just trying to access the next nested prop.

For example (using the above example), with the current API, the intellisense for observable.ping. gives: _isScalar, _trySubscribe, _subscribe, forEach, lift, operator, pipe, pong, source, subscribe, toPromise

But with the proposed API, the intellisense for observable._.ping. gives: pong and the intellisense for observable._.ping.$. gives: _isScalar, _trySubscribe, _subscribe, forEach, lift, operator, pipe, source, subscribe, toPromise

I think that it will clean up the dev experience quite a bit. ( and now would be the time to do breaking changes to the API (since you are on v0.1) ). What do you think?

kosich commented 3 years ago

Heya, @markwhitfeld! Glad that you found it worth exploring πŸ™Œ

The name collision and IntelliSense pollution is an old issue, yep. Before diving deep, note that currently we can workaround naming conflict via .pipe(pluck('pipe')).

I really hoped for some rxjs@7 release cleanup, but it seems they're planning to make most of internals private only in rxjs@8. While it'll help with the pollution, we'll still have name collision problem. And who knows when v8 is released.

Let's explore our options:

_ and $ modes

A very interesting study, thanks for sharing! With the modes system you suggest, we seem to have only one possible collision: $. But it's a bit more verbose and is not backward compatible.

We could also default to the _ "object" mode and use _ only in "observable" mode:

observable.p.$.subscribe(console.log); // > πŸ‘
observable.ping.$.pipe(map(o => o.pong))._.hello.$.subscribe(console.log); // > world

Type restriction

I like @tylergrinn 's original idea of restricting proxied underlying types, but it also has its downsides: some deep value (or even function's return value) can conflict with our API β€” and user will have to throw away the proxying altogether.

Symbols

Option 3 is to explore Symbols. RxJS exports observable symbol that we can use to narrow Observable footprint while keeping our proxy object Observable-compatible. So the intellisense will only show pipe and subscribe in addition to proxied type. Here's a rough TS sketch:

interface ObservableLike<T> extends InteropObservable<T> {
  [Symbol.observable](): Observable<T>;
  pipe(...args: any[]): any;
  subscribe(...args: any[]): any;
}

function proxify<T>(source: Observable<T>): ObservableLike<T> {
  // proxying is missing here, but the point is to show narrowed Observable footprint
  return ({
    [observable]: () => source,
    pipe(...args: any[]): any {},
    subscribe(...args: any[]): any {}
  } as any) as ObservableLike<T>;
}

let source = timer(0, 1000).pipe(mapTo('!'));
let value = proxify(source);

zip(
  timer(0, 1000),
  value
).subscribe(console.log); // [0,'!'] ...

online playground.

I like this option most because its backwards and forwards (since rxjs@8 will internalize most props, so we'll drop this hack) compatible. Though it still has the conflicting name issue and requires more RnD.


Let me know what you think! Also, if you have particular use-cases / nuances on your mind β€” please share!

Cheers πŸ™‚

markwhitfeld commented 3 years ago

@kosich Thank you for the really comprehensive reply! Yes, I do realise that it is a breaking change... which is actually allowed for the 0.x versions in the Semantic versioning spec (if you follow that strategy - see https://semver.org/#spec-item-4 ) πŸ˜‰. But this is 100% your choice on which way your API goes πŸ‘

For my particular use case, I really do need the clearly defined modes. The user would start with something in the object mode. I understand that this may diverge from your goals somewhat. If you are not happy to go in this direction with your lib, I may build some of this functionality into my lib (I will credit you and your lib of course!).

I may actually end up diverging anyway because my lib will manage the subscriptions for the user and I may have the $ returning and Observable, and also having $(next => ...) being short-hand for subscribing (but returningvoid` because of the lack of the need to unsubscribe).

kosich commented 3 years ago

@markwhitfeld, I can appreciate benefits of working in modes, but I'd currently prefer keep the API simpler, meaning mixed Observable / Object props.

I also considered supporting both APIs, but the typings and proxying logic will get too complicated (btw, I tried custom APIs in #4). And there's no easy way atm to override this lib's types in a recursive way (related to inability to define generic generics in TS). So with the possibility of your API diverging soon, I think it makes sense to try a separate codebase.

Please feel free to adjust this lib's sources / types to your needs, I'd be glad if your lib could benefit from it! Good luck and don't forget to share your findings! πŸ™‚

P.S.: btw, check out my other rxjs lib rxjs-autorun β€” it might be related to the "manage subscriptions" concept.