An interesting story about type inference with RxJs and typescript

Viewed 150

I have the following code

  let obs$: Observable<string>
   obs$.pipe(
        tap(data => {
          // do stuff
        }),
      );

If I use VSCode and hover over data variable, I see that this is of type string as expected.

Then I add ingnoreElements after tap

obs$.pipe(
    tap(data => {
       // do stuff
    }),
    ignoreElements()
  );

Now, if I hover over data, type inference tells me that data is of type any.

This seems like a retroactive inference. Which is the reason behind this type inference behavior?

Here a stackblitz to reproduce this situation.

THE ANSWER provided by the response from @Andrei Gătej

The whole mystery lays in the way ignoreElements() is declared, i.e.

export declare function ignoreElements(): OperatorFunction<any, never>;

Now, you place ignoreElements() after an operator like tap(), which is declared like this

export declare function tap<T>(observer: PartialObserver<T>): MonoTypeOperatorFunction<T>;

and basically within pipe() you have a chain such as this

MonoTypeOperatorFunction<T>,
OperatorFunction<any, never>

which pipe() interprets as: "T" is a generic type, "any" is a real type, then since ignoreElements() expects "any" then "T" must be of type "any"

And this explains the mystery.

Now there are few possible solutions. One way is to isolate ignoreElements() into a different pipe() like this

obs$.pipe(
    tap(data => {
       // do stuff
    }),
  )
  .pipe(
    ignoreElements()
  );

If you do this the variable data is inferred of type string which is what we want.

A more elegant solution though would be to change the declaration of ignoreElements(). If you declare it like this

export declare function ignoreElements<T>(): OperatorFunction<T, never>;

everything works as expected without the need of doing any trick.

It may be worth to suggest a change in the declaration of ignoreElements().

1 Answers

This is a very interesting question!

Let's have a look at ignoreElements()'s types:

export function ignoreElements(): OperatorFunction<any, never> {
  return function ignoreElementsOperatorFunction(source: Observable<any>) {
    return source.lift(new IgnoreElementsOperator());
  };
}

Source.

The most important thing to notice here is OperatorFunction, which is defined as follows:

export interface UnaryFunction<T, R> { (source: T): R; }

export interface OperatorFunction<T, R> extends UnaryFunction<Observable<T>, Observable<R>> {}

Source.

So UnaryFunction<T, R> describes a function that receives an argument of type T and returns something of type R.

Similarly, OperatorFunction<T, R> is a function which accepts an argument which is an Observable of type T and returns an Observable of type R.


Going back to ignoreElements()'s types: OperatorFunction<any, never>. So, it means that ignoreElements returns a function whose argument is an Observable<any> and whose return type is Observable<never>.

Now let's see the types of pipe():

pipe(): Observable<T>;
pipe<A>(op1: OperatorFunction<T, A>): Observable<A>;
pipe<A, B>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>): Observable<B>;
pipe<A, B, C>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>, op3: OperatorFunction<B, C>): Observable<C>;

Source.

Every operator returns an OperationFunction. For instance, from

pipe<A, B>(op1: OperatorFunction<T, A>, op2: OperatorFunction<A, B>): Observable<B>;

we can see that the return type of the first operator is the input type for the second operator and so forth. This is how types are inferred.

Also, T can be inferred from the Observable's constructor:

constructor(subscribe?: (this: Observable<T>, subscriber: Subscriber<T>) => TeardownLogic) {
  if (subscribe) {
    this._subscribe = subscribe;
  }
}

class Subscriber<T> implements Observer<T> { /* ... */ }

export interface Observer<T> {
  closed?: boolean;
  next: (value: T) => void; // ! 
  error: (err: any) => void;
  complete: () => void;
}

Source.

Related