Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reserved keys in observable type #9

Open
amygrinn opened this issue Dec 17, 2020 · 5 comments
Open

Reserved keys in observable type #9

amygrinn opened this issue Dec 17, 2020 · 5 comments

Comments

@amygrinn
Copy link
Contributor

amygrinn commented Dec 17, 2020

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
Copy link
Owner

kosich commented Dec 17, 2020

This was bothering me too! Will take a look at this and report back.
Thanks! 🙂

@markwhitfeld
Copy link

markwhitfeld commented May 13, 2021

@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
Copy link
Owner

kosich commented May 15, 2021

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
Copy link

markwhitfeld commented May 16, 2021

@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 returning void` because of the lack of the need to unsubscribe).

@kosich
Copy link
Owner

kosich commented May 17, 2021

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants